datajunction-ui
Version:
DataJunction UI
1 lines • 119 kB
Source Map (JSON)
{"version":3,"file":"index-DIi7ls1d.cjs","sources":["../src/app/pages/AddEditNodePage/MetricQueryField.jsx","../src/app/pages/AddEditNodePage/catalogTables.js","../src/app/pages/AddEditNodePage/djNodeBadges.js","../src/app/pages/AddEditNodePage/djEditorTheme.js","../src/app/pages/AddEditNodePage/NodeQueryField.jsx","../src/app/pages/AddEditNodePage/MetricMetadataFields.jsx","../src/app/pages/AddEditNodePage/UpstreamNodeField.jsx","../src/app/pages/AddEditNodePage/OwnersField.jsx","../src/app/pages/AddEditNodePage/TagsField.jsx","../src/app/pages/AddEditNodePage/NamespaceField.jsx","../src/app/pages/AddEditNodePage/AlertMessage.jsx","../src/app/pages/AddEditNodePage/DisplayNameField.jsx","../src/app/pages/AddEditNodePage/DescriptionField.jsx","../src/app/pages/AddEditNodePage/NodeModeField.jsx","../src/app/pages/AddEditNodePage/RequiredDimensionsSelect.jsx","../src/app/pages/AddEditNodePage/ColumnsSelect.jsx","../src/app/pages/AddEditNodePage/CustomMetadataField.jsx","../src/app/pages/AddEditNodePage/index.jsx"],"sourcesContent":["/**\n * Metric aggregate expression input field, which consists of a CodeMirror SQL\n * editor with autocompletion for node columns and syntax highlighting.\n *\n * Supports both:\n * - Regular metrics: autocomplete from upstream node columns\n * - Derived metrics: autocomplete from available metric names\n */\nimport React from 'react';\nimport { ErrorMessage, Field, useFormikContext } from 'formik';\nimport CodeMirror from '@uiw/react-codemirror';\nimport { langs } from '@uiw/codemirror-extensions-langs';\n\nexport const MetricQueryField = ({ djClient, value }) => {\n const [schema, setSchema] = React.useState({});\n const [availableMetrics, setAvailableMetrics] = React.useState([]);\n const formik = useFormikContext();\n const upstreamNode = formik.values['upstream_node'];\n // Memoize the sql extension on schema so it only rebuilds when the schema\n // actually changes. Without this, every parent render produces a new\n // sqlExt reference and CodeMirror re-registers extensions — which closes\n // any open autocomplete dropdown.\n const sqlExt = React.useMemo(() => langs.sql({ schema }), [schema]);\n\n // Load available metrics for derived metric autocomplete\n React.useEffect(() => {\n async function fetchMetrics() {\n try {\n const metrics = await djClient.metrics();\n setAvailableMetrics(metrics || []);\n } catch (err) {\n console.error('Failed to load metrics for autocomplete:', err);\n }\n }\n fetchMetrics();\n }, [djClient]);\n\n // Build the autocomplete schema once when upstream node or metrics change.\n // Doing this from inside an autocomplete source (per keystroke) caused a\n // re-render mid-completion which dismissed the dropdown.\n React.useEffect(() => {\n let cancelled = false;\n async function loadSchema() {\n const next = {};\n if (upstreamNode && upstreamNode.trim() !== '') {\n try {\n const nodeDetails = await djClient.node(upstreamNode);\n nodeDetails?.columns?.forEach(col => {\n next[col.name] = [];\n });\n } catch (err) {\n console.error('Failed to load upstream node columns:', err);\n }\n }\n availableMetrics.forEach(metricName => {\n next[metricName] = [];\n });\n if (!cancelled) setSchema(next);\n }\n loadSchema();\n return () => {\n cancelled = true;\n };\n }, [djClient, upstreamNode, availableMetrics]);\n\n const updateFormik = val => {\n formik.setFieldValue('aggregate_expression', val);\n };\n\n // Determine the label and help text based on whether upstream is selected\n const isDerivedMode = !upstreamNode || upstreamNode.trim() === '';\n const labelText = isDerivedMode\n ? 'Derived Metric Expression *'\n : 'Aggregate Expression *';\n const helpText = isDerivedMode\n ? 'Reference other metrics using their full names (e.g., namespace.metric_name / namespace.other_metric)'\n : 'Use aggregate functions on columns from the upstream node (e.g., SUM(column_name))';\n\n return (\n <div className=\"QueryInput MetricQueryInput NodeCreationInput\">\n <ErrorMessage name=\"query\" component=\"span\" />\n <label htmlFor=\"Query\">{labelText}</label>\n <p\n className=\"field-help-text\"\n style={{ fontSize: '0.85em', color: '#666', marginBottom: '8px' }}\n >\n {helpText}\n </p>\n <Field\n type=\"textarea\"\n style={{ display: 'none' }}\n as=\"textarea\"\n name=\"aggregate_expression\"\n id=\"Query\"\n />\n <div role=\"button\" tabIndex={0} className=\"relative flex bg-[#282a36]\">\n <CodeMirror\n id={'aggregate_expression'}\n name={'aggregate_expression'}\n extensions={[sqlExt]}\n value={value}\n options={{\n theme: 'default',\n lineNumbers: true,\n }}\n width=\"100%\"\n height=\"100px\"\n style={{\n margin: '0 0 23px 0',\n flex: 1,\n fontSize: '110%',\n textAlign: 'left',\n }}\n onChange={(value, viewUpdate) => {\n updateFormik(value);\n }}\n />\n </div>\n </div>\n );\n};\n","// Matches catalog.schema.table (3-part dot-separated identifiers, backtick-quoted or plain)\nconst CATALOG_TABLE_PATTERN =\n /`?([a-zA-Z_][a-zA-Z0-9_]*)`?\\.`?([a-zA-Z_][a-zA-Z0-9_]*)`?\\.`?([a-zA-Z_][a-zA-Z0-9_]*)`?/;\n\n/**\n * Extracts all `catalog.schema.table` references in `sql` whose catalog is in\n * `knownCatalogs` (case-insensitive). Returns deduplicated [catalog, schema, table] tuples.\n */\nexport function extractCatalogTables(sql, knownCatalogs) {\n const re = new RegExp(CATALOG_TABLE_PATTERN.source, 'g');\n const known = new Set(knownCatalogs.map(c => c.toLowerCase()));\n const seen = new Set();\n const out = [];\n let m;\n while ((m = re.exec(sql)) !== null) {\n const [, catalog, schema, table] = m;\n if (!known.has(catalog.toLowerCase())) continue;\n const key = `${catalog}.${schema}.${table}`;\n if (seen.has(key)) continue;\n seen.add(key);\n out.push([catalog, schema, table]);\n }\n return out;\n}\n","/**\n * CodeMirror 6 extension that styles DJ node references in the SQL editor\n * as inline chips with a status indicator.\n *\n * Catalog-qualified tables (`catalog.schema.table`, where `catalog` is in\n * the known-catalogs list) get wrapped in a chip whose color + trailing icon\n * reflect the auto-register flow:\n *\n * ⟳ registering ✓ valid (registered or already known) ✗ invalid\n *\n * The extension is purely a display layer — it reads status from a\n * caller-provided `getStatus(key)` and never writes data itself. The host\n * component (NodeQueryField) drives the registration flow and dispatches\n * `refreshBadges` whenever status changes so decorations rebuild even when\n * the doc itself hasn't changed.\n */\nimport { ViewPlugin, Decoration, hoverTooltip } from '@codemirror/view';\nimport { StateEffect } from '@codemirror/state';\n\n// Match dotted identifiers of any length (2+ segments). Greedy `+` keeps\n// `a.b.c.d` as a single match instead of splitting into `a.b` + `.c.d`.\n// Each segment is `[a-zA-Z_]\\w*`, optionally backtick-quoted.\nconst DOTTED_REF_RE =\n /`?[a-zA-Z_][a-zA-Z0-9_]*`?(?:\\.`?[a-zA-Z_][a-zA-Z0-9_]*`?)+/g;\n\nfunction unbacktick(s) {\n return s.replace(/`/g, '');\n}\n\nexport const refreshBadges = StateEffect.define();\n\nfunction classForStatus(statusKind) {\n switch (statusKind) {\n case 'registering':\n return 'dj-node-chip--registering';\n case 'valid':\n return 'dj-node-chip--valid';\n case 'warning':\n return 'dj-node-chip--warning';\n case 'invalid':\n return 'dj-node-chip--invalid';\n default:\n return 'dj-node-chip--unknown';\n }\n}\n\nfunction buildDecorations(view, getStatus, getKnownCatalogs) {\n const known = new Set((getKnownCatalogs() || []).map(c => c.toLowerCase()));\n const doc = view.state.doc;\n const ranges = [];\n\n for (const { from, to } of view.visibleRanges) {\n const slice = doc.sliceString(from, to);\n DOTTED_REF_RE.lastIndex = 0;\n let m;\n while ((m = DOTTED_REF_RE.exec(slice)) !== null) {\n const whole = m[0];\n const key = unbacktick(whole);\n const status = getStatus(key);\n const segments = key.split('.');\n\n let refType;\n if (status?.refType) {\n // validateNode told us what this is; honor that.\n refType = status.refType;\n } else if (\n segments.length === 3 &&\n known.has(segments[0].toLowerCase())\n ) {\n // 3-part with a known catalog: candidate source registration.\n refType = 'source';\n } else {\n // No status entry and not a catalog-qualified table — skip.\n // This avoids false positives on `alias.column` patterns.\n continue;\n }\n\n const cls = `dj-node-chip dj-node-chip--${refType} ${classForStatus(\n status?.kind,\n )}`;\n const start = from + m.index;\n const end = start + whole.length;\n ranges.push({\n from: start,\n to: end,\n deco: Decoration.mark({\n class: cls,\n attributes: status?.message ? { title: status.message } : undefined,\n }),\n });\n }\n }\n\n ranges.sort((a, b) => a.from - b.from || a.to - b.to);\n return Decoration.set(ranges.map(r => r.deco.range(r.from, r.to)));\n}\n\n/**\n * Factory. Returns a CodeMirror extension that decorates DJ node refs.\n *\n * getStatus(key) — returns { kind, message? } | undefined for `catalog.schema.table`\n * getKnownCatalogs() — returns string[] (lowercase catalog names)\n *\n * Both are read on every rebuild, so pass ref-backed closures and update\n * the ref synchronously before dispatching `refreshBadges` — otherwise the\n * extension reads pre-update state.\n */\nexport function djNodeBadges({ getStatus, getKnownCatalogs }) {\n return ViewPlugin.fromClass(\n class {\n constructor(view) {\n this.decorations = buildDecorations(view, getStatus, getKnownCatalogs);\n }\n update(update) {\n const refreshed = update.transactions.some(tr =>\n tr.effects.some(e => e.is(refreshBadges)),\n );\n if (update.docChanged || update.viewportChanged || refreshed) {\n this.decorations = buildDecorations(\n update.view,\n getStatus,\n getKnownCatalogs,\n );\n }\n }\n },\n { decorations: v => v.decorations },\n );\n}\n\n/**\n * Find the dotted ref under a given document position. Returns\n * `{ key, from, to }` if the position falls inside a 2+-segment dotted\n * identifier, else null. Used by the hover tooltip to identify which chip\n * the cursor is on.\n */\nexport function refAtPos(view, pos) {\n const line = view.state.doc.lineAt(pos);\n DOTTED_REF_RE.lastIndex = 0;\n let m;\n while ((m = DOTTED_REF_RE.exec(line.text)) !== null) {\n const start = line.from + m.index;\n const end = start + m[0].length;\n if (pos >= start && pos <= end) {\n return { key: unbacktick(m[0]), from: start, to: end };\n }\n }\n return null;\n}\n\nexport function renderTooltipDom(status, refKey) {\n const wrap = document.createElement('div');\n wrap.className = 'dj-node-tooltip';\n\n const node = status.node || {};\n\n // Header — type pill + qualified name.\n const header = document.createElement('div');\n header.className = 'dj-node-tooltip__header';\n\n const pill = document.createElement('span');\n pill.className = `dj-node-chip dj-node-chip--${status.refType || 'node'}`;\n pill.textContent = status.refType || 'node';\n header.appendChild(pill);\n\n const name = document.createElement('span');\n name.className = 'dj-node-tooltip__name';\n name.textContent = node.name || refKey;\n header.appendChild(name);\n\n wrap.appendChild(header);\n\n // Special-case transient + error states — no metadata grid, just one line.\n if (status.kind === 'invalid') {\n const note = document.createElement('div');\n note.className = 'dj-node-tooltip__note';\n note.textContent = 'Not found in DJ.';\n wrap.appendChild(note);\n return wrap;\n }\n if (status.kind === 'registering') {\n const note = document.createElement('div');\n note.className = 'dj-node-tooltip__note';\n note.textContent = 'Registering…';\n wrap.appendChild(note);\n return wrap;\n }\n\n // Metadata grid (2 columns of label/value pairs).\n const grid = document.createElement('div');\n grid.className = 'dj-node-tooltip__grid';\n const addCell = (label, value) => {\n if (value == null || value === '') return;\n const cell = document.createElement('div');\n cell.className = 'dj-node-tooltip__cell';\n const l = document.createElement('span');\n l.className = 'dj-node-tooltip__label';\n l.textContent = label;\n const v = document.createElement('span');\n v.className = 'dj-node-tooltip__value';\n v.textContent = value;\n cell.appendChild(l);\n cell.appendChild(v);\n grid.appendChild(cell);\n };\n if (node.status) addCell('Status', node.status);\n if (node.version) addCell('Version', node.version);\n if (node.mode) addCell('Mode', node.mode);\n if (Array.isArray(node.columns)) {\n addCell('Columns', String(node.columns.length));\n }\n if (grid.childElementCount > 0) wrap.appendChild(grid);\n\n // Description (truncated if long).\n if (node.description) {\n const d = document.createElement('div');\n d.className = 'dj-node-tooltip__description';\n const text = node.description;\n d.textContent = text.length > 280 ? text.slice(0, 277) + '…' : text;\n wrap.appendChild(d);\n }\n\n // Column list — scrollable. The point of the popover for someone writing\n // SQL is to see what's available to SELECT without leaving the editor.\n if (Array.isArray(node.columns) && node.columns.length > 0) {\n const list = document.createElement('div');\n list.className = 'dj-node-tooltip__columns';\n for (const col of node.columns) {\n const row = document.createElement('div');\n row.className = 'dj-node-tooltip__col-row';\n\n const n = document.createElement('span');\n n.className = 'dj-node-tooltip__col-name';\n n.textContent = col.name;\n row.appendChild(n);\n\n // Mark columns that link to a dimension — useful signal for cube/SQL work.\n if (col.dimension || col.dimension_column) {\n const tag = document.createElement('span');\n tag.className = 'dj-node-tooltip__col-tag';\n tag.textContent = 'dim';\n tag.title = col.dimension?.name\n ? `Links to ${col.dimension.name}`\n : 'Linked dimension';\n row.appendChild(tag);\n }\n\n const t = document.createElement('span');\n t.className = 'dj-node-tooltip__col-type';\n // Struct/array types can be enormous (multi-line) — keep it on one line\n // and let CSS clamp; full info is on the node page.\n t.textContent = (col.type || '').split('\\n')[0];\n t.title = col.type || '';\n row.appendChild(t);\n\n list.appendChild(row);\n }\n wrap.appendChild(list);\n }\n\n // Footer link.\n if (node.name) {\n const foot = document.createElement('div');\n foot.className = 'dj-node-tooltip__footer';\n const link = document.createElement('a');\n link.href = `/nodes/${encodeURIComponent(node.name)}`;\n link.target = '_blank';\n link.rel = 'noreferrer noopener';\n link.textContent = 'Open node →';\n foot.appendChild(link);\n wrap.appendChild(foot);\n }\n\n return wrap;\n}\n\n// Cache of node-detail fetches so re-hovering the same chip doesn't refetch.\n// Lives at module scope (one editor + dom = one cache) — small enough that\n// not bothering with an LRU.\nconst nodeDetailsCache = new Map();\n\n/**\n * Hover tooltip extension. Pairs with djNodeBadges so the same\n * `getStatus` source of truth drives the popover content. Additionally\n * lazy-fetches the full node (columns, description, mode, version) on\n * hover via `fetchNodeDetails`, and re-renders the tooltip in place\n * when the promise resolves.\n */\nexport function djNodeHoverTooltip({\n getStatus,\n getKnownCatalogs,\n fetchNodeDetails,\n}) {\n return hoverTooltip(\n (view, pos) => {\n const hit = refAtPos(view, pos);\n if (!hit) return null;\n const status = getStatus(hit.key);\n if (!status) {\n // No status entry; only show tooltip for catalog-qualified refs that\n // would render as a chip — otherwise we'd pop up on every `t.column`.\n const known = new Set(\n (getKnownCatalogs() || []).map(c => c.toLowerCase()),\n );\n const segments = hit.key.split('.');\n if (segments.length !== 3 || !known.has(segments[0].toLowerCase())) {\n return null;\n }\n }\n return {\n pos: hit.from,\n end: hit.to,\n above: true,\n create: () => {\n const baseStatus = status || { refType: 'source' };\n const dom = renderTooltipDom(baseStatus, hit.key);\n\n // Lazy-fetch the full node so we can show columns + description.\n // The validateNode dep object only carries {name, type, status}.\n if (\n fetchNodeDetails &&\n baseStatus.kind !== 'invalid' &&\n baseStatus.kind !== 'registering'\n ) {\n const cached = nodeDetailsCache.get(hit.key);\n const promise = cached || fetchNodeDetails(hit.key);\n if (!cached) nodeDetailsCache.set(hit.key, promise);\n\n Promise.resolve(promise)\n .then(full => {\n if (!full) return;\n const richDom = renderTooltipDom(\n {\n ...baseStatus,\n node: { ...(baseStatus.node || {}), ...full },\n },\n hit.key,\n );\n if (dom.parentNode) {\n dom.parentNode.replaceChild(richDom, dom);\n } else {\n // Tooltip already detached — swap children so future refs\n // see the rich content.\n dom.replaceChildren(...richDom.childNodes);\n }\n })\n .catch(() => {\n // Soft-fail — leave the bare header tooltip in place.\n });\n }\n\n return { dom };\n },\n };\n },\n { hoverTime: 150 },\n );\n}\n","/**\n * CodeMirror 6 theme tuned to share a palette with the rest of the DJ UI\n * (especially the `.node_type__*` pills and the `.dj-node-chip--*` chips\n * rendered inline by djNodeBadges). The goal: syntax colors and chip\n * colors come from the same family, so chips read as theme-native rather\n * than as foreign decoration.\n */\nimport { EditorView } from '@codemirror/view';\nimport { HighlightStyle, syntaxHighlighting } from '@codemirror/language';\nimport { tags as t } from '@lezer/highlight';\n\n// Palette — kept in one place so it stays in sync with node-creation.scss.\nconst palette = {\n bg: '#fbfbfd',\n surface: '#f6f6f9',\n gutter: '#9aa2b1',\n selection: '#dbe2ec',\n caret: '#3b3f46',\n text: '#27303d',\n muted: '#8b94a5',\n keyword: '#6b3aa0', // matches cube/dimension violet family\n string: '#0e7c5a', // forest green\n number: '#a96621', // warm amber (same as dimension chip)\n operator: '#475569',\n functionName: '#0063b4', // transform blue\n punctuation: '#5c6573',\n comment: '#9ca3af',\n};\n\nexport const djEditorTheme = EditorView.theme(\n {\n '&': {\n color: palette.text,\n backgroundColor: palette.bg,\n fontSize: '13px',\n },\n '.cm-content': {\n caretColor: palette.caret,\n fontFamily:\n '\"JetBrains Mono\", \"SF Mono\", Menlo, Consolas, \"Liberation Mono\", monospace',\n },\n '.cm-cursor, .cm-dropCursor': { borderLeftColor: palette.caret },\n '&.cm-focused .cm-selectionBackground, ::selection, .cm-selectionBackground':\n {\n backgroundColor: palette.selection,\n },\n '.cm-activeLine': {\n backgroundColor: 'rgba(99, 102, 241, 0.05)',\n },\n '.cm-gutters': {\n backgroundColor: palette.surface,\n color: palette.gutter,\n border: 'none',\n },\n '.cm-activeLineGutter': {\n backgroundColor: 'rgba(99, 102, 241, 0.08)',\n color: palette.text,\n },\n '.cm-lineNumbers .cm-gutterElement': {\n padding: '0 8px 0 6px',\n },\n '.cm-foldGutter .cm-gutterElement': {\n color: palette.muted,\n },\n '.cm-tooltip': {\n background: '#ffffff',\n border: '1px solid #e2e8f0',\n borderRadius: '6px',\n boxShadow: '0 4px 12px rgba(15, 23, 42, 0.06)',\n },\n '.cm-tooltip-autocomplete > ul > li[aria-selected]': {\n background: '#eef2ff',\n color: palette.text,\n },\n '.cm-matchingBracket, .cm-nonmatchingBracket': {\n backgroundColor: '#fef3c7',\n outline: 'none',\n },\n '.cm-panels': {\n backgroundColor: palette.surface,\n color: palette.text,\n },\n },\n { dark: false },\n);\n\nconst highlight = HighlightStyle.define([\n {\n tag: [t.keyword, t.modifier, t.controlKeyword, t.operatorKeyword],\n color: palette.keyword,\n fontWeight: '500',\n },\n { tag: [t.string, t.special(t.string)], color: palette.string },\n { tag: [t.number, t.bool, t.null], color: palette.number },\n {\n tag: [t.function(t.variableName), t.function(t.propertyName)],\n color: palette.functionName,\n },\n {\n tag: [t.operator, t.compareOperator, t.arithmeticOperator, t.logicOperator],\n color: palette.operator,\n },\n {\n tag: [t.punctuation, t.paren, t.brace, t.bracket, t.separator],\n color: palette.punctuation,\n },\n {\n tag: [t.variableName, t.propertyName, t.attributeName],\n color: palette.text,\n },\n { tag: [t.typeName, t.className], color: palette.functionName },\n {\n tag: [t.comment, t.lineComment, t.blockComment],\n color: palette.comment,\n fontStyle: 'italic',\n },\n { tag: t.invalid, color: '#cc2222' },\n]);\n\nexport const djEditorExtensions = [\n djEditorTheme,\n syntaxHighlighting(highlight),\n];\n","/**\n * SQL query input field, which consists of a CodeMirror SQL editor with autocompletion\n * (for node names and columns) and syntax highlighting.\n */\nimport React from 'react';\nimport { ErrorMessage, Field, useFormikContext } from 'formik';\nimport CodeMirror from '@uiw/react-codemirror';\nimport { langs } from '@uiw/codemirror-extensions-langs';\nimport { extractCatalogTables } from './catalogTables';\nimport {\n djNodeBadges,\n djNodeHoverTooltip,\n refreshBadges,\n} from './djNodeBadges';\nimport { djEditorExtensions } from './djEditorTheme';\n\n// Friendly labels for the most common DJ error codes the validate endpoint\n// returns from a node query. Falls back to a title-cased version of the code.\nconst ERROR_CODE_LABELS = {\n INVALID_SQL_QUERY: 'Invalid SQL',\n TYPE_INFERENCE: 'Type inference',\n INVALID_COLUMN: 'Invalid column',\n INVALID_ARGUMENTS_TO_FUNCTION: 'Invalid function arguments',\n MISSING_COLUMNS: 'Missing columns',\n UNKNOWN_NODE: 'Unknown node',\n MISSING_PARENT: 'Missing parent',\n NODE_TYPE_ERROR: 'Node type',\n NOT_IMPLEMENTED_ERROR: 'Not implemented',\n};\n\nconst errorLabel = code => {\n if (code === undefined || code === null || code === '') return 'Error';\n const key = String(code);\n if (ERROR_CODE_LABELS[key]) return ERROR_CODE_LABELS[key];\n return key\n .toLowerCase()\n .split('_')\n .map(word => (word ? word[0].toUpperCase() + word.slice(1) : ''))\n .join(' ');\n};\n\nexport const NodeQueryField = ({ djClient, value }) => {\n // Schema is `{ [nodeName]: string[] }` — passed to @codemirror/lang-sql so\n // it can offer table + column autocompletion. Must be an OBJECT, not array,\n // and every update must produce a new reference so React re-renders and\n // langs.sql() rebuilds with the latest map.\n const [schema, setSchema] = React.useState({});\n const formik = useFormikContext();\n const sqlExt = React.useMemo(() => langs.sql({ schema }), [schema]);\n const autoRegisterTimer = React.useRef(null);\n const validateTimer = React.useRef(null);\n const registeredTables = React.useRef(new Set());\n // useRef so the onChange closure always sees the latest catalog list\n const knownCatalogsRef = React.useRef([]);\n\n // Query-level validation errors (parse failures, type-inference failures, etc.)\n // surfaced from /nodes/validate's `errors` field. Rendered as a banner under\n // the editor so the user sees *why* their SQL is invalid, not just that it is.\n const [queryErrors, setQueryErrors] = React.useState([]);\n\n // Per-`catalog.schema.table` registration status, surfaced as badges in\n // the editor. Mirrored into a ref so the CodeMirror extension (built once,\n // memoized below) can read the latest value without rebuilding.\n const [tableStatus, setTableStatus] = React.useState({});\n const tableStatusRef = React.useRef(tableStatus);\n React.useEffect(() => {\n tableStatusRef.current = tableStatus;\n }, [tableStatus]);\n const editorViewRef = React.useRef(null);\n const setStatus = React.useCallback((key, status) => {\n // Update the ref synchronously so the ViewPlugin's getStatus() closure\n // sees the new value on the redraw we're about to dispatch.\n // setTableStatus only schedules the React rerender (next tick) which is\n // too late for the dispatch below.\n tableStatusRef.current = { ...tableStatusRef.current, [key]: status };\n setTableStatus(tableStatusRef.current);\n if (editorViewRef.current) {\n editorViewRef.current.dispatch({ effects: refreshBadges.of(null) });\n }\n }, []);\n\n React.useEffect(() => {\n if (typeof djClient.catalogs !== 'function') return;\n Promise.resolve(djClient.catalogs())\n .then(catalogs => {\n knownCatalogsRef.current = (catalogs || []).map(c =>\n c.name.toLowerCase(),\n );\n })\n .catch(() => {\n // Auto-register is best-effort; failing to load catalogs just disables it.\n });\n }, [djClient]);\n\n const initialAutocomplete = async context => {\n // Based on the parsed prefix, we load node names with that prefix\n // into the autocomplete schema. At this stage we don't load the columns\n // to save on unnecessary calls\n const word = context.matchBefore(/[\\.\\w]*/);\n const matches = await djClient.nodes(word.text);\n setSchema(prev => {\n const next = { ...prev };\n let changed = false;\n for (const nodeName of matches) {\n if (next[nodeName] === undefined) {\n next[nodeName] = [];\n changed = true;\n }\n }\n return changed ? next : prev;\n });\n };\n\n const updateFormik = val => {\n formik.setFieldValue('query', val);\n };\n\n const updateAutocomplete = async (value, _) => {\n // If a particular node has been chosen, load the columns of that node into\n // the autocomplete schema for column-level autocompletion. Mutate via a\n // functional setState so every update produces a new map reference and\n // langs.sql sees the change.\n for (const nodeName of Object.keys(schema)) {\n if (value.includes(nodeName) && schema[nodeName].length === 0) {\n const nodeDetails = await djClient.node(nodeName);\n const cols = (nodeDetails?.columns || []).map(col => col.name);\n setSchema(prev => ({ ...prev, [nodeName]: cols }));\n }\n }\n\n // Auto-register any catalog-qualified tables typed in the SQL\n if (knownCatalogsRef.current.length > 0) {\n clearTimeout(autoRegisterTimer.current);\n autoRegisterTimer.current = setTimeout(\n () => autoRegisterCatalogTables(value),\n 600,\n );\n }\n\n // Validate the in-progress query against DJ to discover which 2-part\n // refs (`namespace.name`) resolve to real nodes vs. are missing.\n clearTimeout(validateTimer.current);\n validateTimer.current = setTimeout(() => validateRefs(value), 700);\n };\n\n const validateRefs = async sql => {\n if (typeof djClient.validateNode !== 'function') return;\n const fv = formik.values || {};\n if (!fv.type) return; // need a node type to validate\n let response;\n try {\n response = await djClient.validateNode(\n fv.type,\n fv.name || '__draft__',\n fv.display_name || 'Draft',\n fv.description || '',\n sql,\n );\n } catch {\n return; // best-effort; ignore network failures\n }\n const json = response?.json || {};\n const deps = json.dependencies || [];\n const missing = json.missing_parents || [];\n setQueryErrors(Array.isArray(json.errors) ? json.errors : []);\n // Update the ref synchronously in one shot before dispatching, so the\n // ViewPlugin only redraws once even though we set many keys.\n const next = { ...tableStatusRef.current };\n for (const dep of deps) {\n // `dependencies` is a list of full node objects ({name, type, status, ...}).\n // Use `type` to color the chip per node kind (source/dimension/…) and\n // `status` to flag nodes that resolve but are themselves unhealthy\n // (e.g. broken upstream, drifted columns). We also keep the whole\n // object around so the hover popover can show description / version\n // / mode without re-fetching.\n const name = typeof dep === 'string' ? dep : dep?.name;\n if (!name) continue;\n const nodeType = typeof dep === 'object' ? dep.type : undefined;\n const nodeStatus = typeof dep === 'object' ? dep.status : undefined;\n const kind = nodeStatus && nodeStatus !== 'valid' ? 'warning' : 'valid';\n next[name] = {\n kind,\n refType: nodeType || 'node',\n node: typeof dep === 'object' ? dep : undefined,\n message:\n kind === 'warning' ? `Node \\`${name}\\` is ${nodeStatus}` : undefined,\n };\n }\n for (const m of missing) {\n // `missing_parents` is a list of bare name strings.\n const name = typeof m === 'string' ? m : m?.name;\n if (!name) continue;\n next[name] = {\n kind: 'invalid',\n refType: 'node',\n message: `Node \\`${name}\\` not found`,\n };\n }\n tableStatusRef.current = next;\n setTableStatus(next);\n if (editorViewRef.current) {\n editorViewRef.current.dispatch({ effects: refreshBadges.of(null) });\n }\n };\n\n const autoRegisterCatalogTables = async sql => {\n const tables = extractCatalogTables(sql, knownCatalogsRef.current);\n for (const [catalog, tableSchema, table] of tables) {\n const key = `${catalog}.${tableSchema}.${table}`;\n if (registeredTables.current.has(key)) continue;\n // If autocomplete already saw this node, DJ knows about it — skip silently\n if (schema[key] !== undefined) {\n registeredTables.current.add(key);\n setStatus(key, { kind: 'valid' });\n continue;\n }\n registeredTables.current.add(key);\n setStatus(key, { kind: 'registering' });\n\n let response;\n try {\n response = await djClient.registerTable(\n catalog,\n tableSchema,\n table,\n '',\n );\n } catch (err) {\n registeredTables.current.delete(key);\n setStatus(key, {\n kind: 'invalid',\n message: err?.message || 'Failed to register table',\n });\n continue;\n }\n const { status, json } = response;\n if (status === 200 || status === 201) {\n const nodeName = json?.name || key;\n const columns = (json?.columns || []).map(col => col.name);\n setSchema(prev => {\n const next = { ...prev, [nodeName]: columns };\n if (table && table !== nodeName) next[table] = columns;\n return next;\n });\n setStatus(key, { kind: 'valid' });\n } else if (status === 409) {\n // 409 = already exists; treat as valid.\n setStatus(key, { kind: 'valid' });\n } else {\n registeredTables.current.delete(key);\n setStatus(key, {\n kind: 'invalid',\n message: json?.message || `Registration failed (HTTP ${status})`,\n });\n }\n }\n };\n\n const badgeExt = React.useMemo(\n () =>\n djNodeBadges({\n getStatus: key => tableStatusRef.current[key],\n getKnownCatalogs: () => knownCatalogsRef.current,\n }),\n [],\n );\n\n const hoverExt = React.useMemo(\n () =>\n djNodeHoverTooltip({\n getStatus: key => tableStatusRef.current[key],\n getKnownCatalogs: () => knownCatalogsRef.current,\n fetchNodeDetails: name => djClient.node(name).catch(() => null),\n }),\n [djClient],\n );\n\n return (\n <div className=\"QueryInput NodeCreationInput\">\n <ErrorMessage name=\"query\" component=\"span\" />\n <label htmlFor=\"Query\">Query *</label>\n <Field\n type=\"textarea\"\n style={{ display: 'none' }}\n as=\"textarea\"\n name=\"query\"\n id=\"Query\"\n />\n {queryErrors.length > 0 && (\n <div className=\"query-validation-errors\" role=\"alert\">\n {queryErrors.map((err, idx) => (\n <div key={idx} className=\"query-validation-error\">\n <span className=\"query-validation-error-code\">\n {errorLabel(err.code)}\n </span>\n <span className=\"query-validation-error-message\">\n {err.message}\n </span>\n </div>\n ))}\n </div>\n )}\n <div role=\"button\" tabIndex={0} className=\"relative flex bg-[#282a36]\">\n <CodeMirror\n id={'query'}\n name={'query'}\n extensions={[\n sqlExt,\n sqlExt.language.data.of({\n autocomplete: initialAutocomplete,\n }),\n ...djEditorExtensions,\n badgeExt,\n hoverExt,\n ]}\n onCreateEditor={view => {\n editorViewRef.current = view;\n }}\n value={value}\n placeholder={\n 'SELECT\\n\\tprimary_key,\\n\\tmeasure1,\\n\\tmeasure2,\\n\\tforeign_key_for_dimension1,\\n\\tforeign_key_for_dimension2\\nFROM source.source_node\\nWHERE ...'\n }\n options={{\n theme: 'default',\n lineNumbers: true,\n }}\n width=\"100%\"\n height=\"400px\"\n style={{\n margin: '0 0 23px 0',\n flex: 1,\n fontSize: '110%',\n textAlign: 'left',\n }}\n onChange={(value, viewUpdate) => {\n updateFormik(value);\n updateAutocomplete(value, viewUpdate);\n }}\n />\n </div>\n </div>\n );\n};\n","/**\n * Metric unit select component\n */\nimport { ErrorMessage, Field } from 'formik';\nimport { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { labelize } from '../../../utils/form';\n\nexport const MetricMetadataFields = () => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n // Metric metadata\n const [metricUnits, setMetricUnits] = useState([]);\n const [metricDirections, setMetricDirections] = useState([]);\n\n // Get metric metadata values\n useEffect(() => {\n const fetchData = async () => {\n const metadata = await djClient.listMetricMetadata();\n setMetricDirections(metadata.directions);\n setMetricUnits(metadata.units);\n };\n fetchData().catch(console.error);\n }, [djClient]);\n\n return (\n <div className=\"MetricMetadataFields node-row\">\n <div className=\"NodeCreationInput NodeModeInput\">\n <ErrorMessage name=\"metric_direction\" component=\"span\" />\n <label htmlFor=\"MetricDirection\">Direction</label>\n <Field as=\"select\" name=\"metric_direction\" id=\"MetricDirection\">\n <option value=\"\"></option>\n {metricDirections.map(direction => (\n <option value={direction} key={direction}>\n {labelize(direction)}\n </option>\n ))}\n </Field>\n </div>\n <div className=\"NodeCreationInput NodeModeInput\">\n <ErrorMessage name=\"metric_unit\" component=\"span\" />\n <label htmlFor=\"MetricUnit\">Unit</label>\n <Field as=\"select\" name=\"metric_unit\" id=\"MetricUnit\">\n <option value=\"\"></option>\n {metricUnits.map(unit => (\n <option value={unit.name} key={unit.name}>\n {unit.label}\n </option>\n ))}\n </Field>\n </div>\n <div className=\"NodeCreationInput NodeModeInput\">\n <ErrorMessage name=\"significant_digits\" component=\"span\" />\n <label htmlFor=\"SignificantDigits\">Significant Digits</label>\n <Field as=\"select\" name=\"significant_digits\" id=\"SignificantDigits\">\n <option value=\"\"></option>\n {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(val => (\n <option value={val} key={val}>\n {val}\n </option>\n ))}\n </Field>\n </div>\n </div>\n );\n};\n","/**\n * Upstream node select field.\n *\n * For regular metrics: Select a source, transform, or dimension node.\n * For derived metrics: Leave empty and reference other metrics directly in the expression.\n */\nimport { ErrorMessage } from 'formik';\nimport { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { FormikSelect } from './FormikSelect';\n\nexport const UpstreamNodeField = ({ defaultValue }) => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n // All available nodes (sources, transforms, dimensions)\n const [availableNodes, setAvailableNodes] = useState([]);\n\n useEffect(() => {\n async function fetchData() {\n const sources = await djClient.nodesWithType('source');\n const transforms = await djClient.nodesWithType('transform');\n const dimensions = await djClient.nodesWithType('dimension');\n const nodes = sources.concat(transforms).concat(dimensions);\n setAvailableNodes(\n nodes.map(node => {\n return {\n value: node,\n label: node,\n };\n }),\n );\n }\n fetchData();\n }, [djClient]);\n\n return (\n <div className=\"NodeCreationInput\">\n <ErrorMessage name=\"upstream_node\" component=\"span\" />\n <label htmlFor=\"upstream_node\">Upstream Node</label>\n <p\n className=\"field-help-text\"\n style={{ fontSize: '0.85em', color: '#666', marginBottom: '8px' }}\n >\n Select a source, transform, or dimension for regular metrics. Leave\n empty for <strong>derived metrics</strong> that reference other metrics\n (e.g., <code>namespace.metric_a / namespace.metric_b</code>).\n </p>\n <span data-testid=\"select-upstream-node\">\n <FormikSelect\n className=\"\"\n defaultValue={defaultValue}\n selectOptions={availableNodes}\n formikFieldName=\"upstream_node\"\n placeholder=\"Select Upstream Node (optional for derived metrics)\"\n isMulti={false}\n isClearable={true}\n />\n </span>\n </div>\n );\n};\n","/**\n * Owner select field\n */\nimport { ErrorMessage, useField } from 'formik';\nimport { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { useCurrentUser } from '../../providers/UserProvider';\nimport { FormikSelect } from './FormikSelect';\n\nexport const OwnersField = ({ defaultValue }) => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const { currentUser } = useCurrentUser();\n const [, meta, helpers] = useField('owners');\n\n const [availableUsers, setAvailableUsers] = useState([]);\n\n useEffect(() => {\n async function fetchData() {\n const users = await djClient.users();\n setAvailableUsers(\n users.map(user => {\n return {\n value: user.username,\n label: user.username,\n };\n }),\n );\n }\n fetchData();\n }, [djClient]);\n\n // Seed the Formik `owners` field — FormikSelect's `defaultValue` prop\n // is ignored because the inner <Select> uses Formik's field state.\n // - Edit mode: seed from `defaultValue` (option objects)\n // - Add mode: seed with currentUser so creators don't have to set\n // themselves as an owner manually\n // Only seed if the field is still empty (the user hasn't touched it).\n useEffect(() => {\n if (meta.value && meta.value.length > 0) return;\n if (defaultValue && defaultValue.length > 0) {\n helpers.setValue(defaultValue.map(d => d.value));\n } else if (currentUser) {\n helpers.setValue([currentUser.username]);\n }\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [defaultValue, currentUser]);\n\n return defaultValue || currentUser ? (\n <div className=\"NodeCreationInput\">\n <ErrorMessage name=\"owners\" component=\"span\" />\n <label htmlFor=\"Owners\">Owners</label>\n <span data-testid=\"select-owner\">\n <FormikSelect\n className=\"\"\n selectOptions={availableUsers}\n formikFieldName=\"owners\"\n placeholder=\"Select Owners\"\n isMulti={true}\n />\n </span>\n </div>\n ) : (\n ''\n );\n};\n","/**\n * Tags select field\n */\nimport { ErrorMessage } from 'formik';\nimport { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { FormikSelect } from './FormikSelect';\n\nexport const TagsField = ({ defaultValue }) => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n // All available tags\n const [tags, setTags] = useState([]);\n\n useEffect(() => {\n const fetchData = async () => {\n const tags = await djClient.listTags();\n setTags(\n tags.map(tag => ({\n value: tag.name,\n label: tag.display_name,\n })),\n );\n };\n fetchData().catch(console.error);\n }, [djClient]);\n\n return (\n <div className=\"TagsInput NodeCreationInput\">\n <ErrorMessage name=\"tags\" component=\"span\" />\n <label htmlFor=\"tags\">Tags</label>\n <span data-testid=\"select-tags\">\n <FormikSelect\n isMulti={true}\n selectOptions={tags}\n formikFieldName=\"tags\"\n className=\"\"\n placeholder=\"Choose Tags\"\n defaultValue={defaultValue}\n />\n </span>\n </div>\n );\n};\n","import { ErrorMessage } from 'formik';\nimport { FormikSelect } from './FormikSelect';\nimport { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../../providers/djclient';\n\nexport const NamespaceField = ({ initialNamespace }) => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n const [namespaces, setNamespaces] = useState([]);\n\n // Get namespaces, only necessary when creating a node\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]);\n\n return (\n <div className=\"NamespaceInput\">\n <ErrorMessage name=\"namespace\" component=\"span\" />\n <label htmlFor=\"namespace\">Namespace *</label>\n <FormikSelect\n className=\"\"\n selectOptions={namespaces}\n formikFieldName=\"namespace\"\n placeholder=\"Choose Namespace\"\n defaultValue={{\n value: initialNamespace,\n label: initialNamespace,\n }}\n />\n </div>\n );\n};\n","import AlertIcon from '../../icons/AlertIcon';\n\nexport const AlertMessage = ({ message }) => {\n return (\n <div className=\"message alert\">\n <AlertIcon />\n {message}\n </div>\n );\n};\n","import { ErrorMessage, Field } from 'formik';\n\nexport const DisplayNameField = () => {\n return (\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 );\n};\n","import { ErrorMessage, Field } from 'formik';\n\nexport const DescriptionField = () => {\n return (\n <div className=\"DescriptionInput NodeCreationInput\">\n <ErrorMessage name=\"description\" component=\"span\" />\n <label htmlFor=\"Description\">Description</label>\n <Field\n type=\"textarea\"\n as=\"textarea\"\n name=\"description\"\n id=\"Description\"\n placeholder=\"Describe your node\"\n />\n </div>\n );\n};\n","import { ErrorMessage, Field } from 'formik';\n\nexport const NodeModeField = () => {\n return (\n <div className=\"NodeModeInput NodeCreationInput\">\n <ErrorMessage name=\"mode\" component=\"span\" />\n <label htmlFor=\"Mode\">Mode</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 );\n};\n","/**\n * Required dimensions select component\n */\nimport { ErrorMessage, useFormikContext } from 'formik';\nimport React, { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { FormikSelect } from './FormikSelect';\n\nexport const RequiredDimensionsSelect = ({\n defaultValue,\n style,\n className = '',\n}) => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n // Used to pull out current form values for node validation\n const { values } = useFormikContext();\n\n // Select options, i.e., the available dimensions\n const [selectOptions, setSelectOptions] = useState([]);\n\n useEffect(() => {\n const fetchData = async () => {\n if (values.upstream_node) {\n const data = await djClient.node(values.upstream_node);\n setSelectOptions(\n data.columns.map(col => {\n return {\n value: col.name,\n label: col.name,\n };\n }),\n );\n }\n };\n fetchData().catch(console.error);\n }, [djClient, values.upstream_node]);\n\n return (\n <div className=\"RequiredDimensionsInput CubeCreationInput\">\n <ErrorMessage name=\"required_dimensions\" component=\"span\" />\n <label htmlFor=\"requiredDimensions\">Required Dimensions</label>\n <FormikSelect\n className={className}\n defaultValue={defaultValue}\n selectOptions={selectOptions}\n formikFieldName={'required_dimensions'}\n placeholder={'Choose Required Dimensions'}\n styles={style}\n isMulti={true}\n />\n </div>\n );\n};\n","/**\n * Component for selecting node columns based on the current form state\n */\nimport { ErrorMessage, useFormikContext } from 'formik';\nimport { useContext, useMemo, useState, useEffect } from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { FormikSelect } from './FormikSelect';\n\nexport const ColumnsSelect = ({\n defaultValue,\n fieldName,\n label,\n labelStyle = {},\n isMulti = false,\n isClearable = true,\n}) => {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n // Used to pull out current form values for node validation\n const { values } = useFormikContext();\n\n // The available columns, determined from validating the node query\n const [availableColumns, setAvailableColumns] = useState([]);\n const [validationError, setValidationError] = useState(null);\n const selectableOptions = useMemo(() => {\n if (availableColumns && availableColumns.length > 0) {\n return availableColumns;\n }\n return [];\n }, [availableColumns]);\n\n // Fetch columns by validating the latest node query\n const fetchColumns = async () => {\n try {\n const { status, json } = await djClient.validateNode(\n values.type,\n values.name,\n values.display_name,\n values.description,\n values.query,\n );\n if (json?.columns) {\n setAvailableColumns(\n json.columns.map(col => ({ value: col.name, label: col.name })),\n );\n setValidationError(null);\n } else {\n // Prefer the structured per-error messages (e.g. parse failures with\n // INVALID_SQL_QUERY) over the generic `Node X is invalid.` summary.\n const detailed = Array.isArray(json?.errors)\n ? json.errors\n .map(e => e.message)\n .filter(Boolean)\n .join('\\n')\n : '';\n setValidationError(detailed || json?.message || null);\n setAvailableColumns([]);\n }\n } catch (error) {\n console.error('Error fetching columns:', error);\n setValidationError(\n error.message ||\n 'Failed to validate query. Please check your query syntax.',\n );\n set