UNPKG

datajunction-ui

Version:
1 lines 110 kB
{"version":3,"file":"index-B8BCP1aG.cjs","sources":["../src/app/components/AddNodeDropdown.jsx","../src/app/pages/NamespacePage/CompactSelect.jsx","../src/app/icons/ChevronIcon.jsx","../src/app/pages/NamespacePage/namespaceShortcuts.js","../src/app/pages/NamespacePage/FolderTree.jsx","../src/app/pages/NamespacePage/NewSubNamespace.jsx","../src/app/pages/NamespacePage/NamespaceNav.jsx","../src/app/pages/NamespacePage/nodeTypes.js","../src/app/pages/NamespacePage/index.jsx"],"sourcesContent":["import { useContext } from 'react';\nimport { useNavigate } from 'react-router-dom';\nimport { useCurrentUser } from '../providers/UserProvider';\nimport DJClientContext from '../providers/djclient';\n\nconst PERSONAL_NS_PREFIX =\n process.env.REACT_APP_PERSONAL_NAMESPACE_PREFIX || 'users';\n\nfunction resolvePersonalNamespace(namespace, username) {\n if (namespace && namespace !== 'default') return namespace;\n if (!username) return 'default';\n const handle = username\n .split('@')[0]\n .toLowerCase()\n .replace(/[^a-z0-9_]/g, '_');\n return `${PERSONAL_NS_PREFIX}.${handle}`;\n}\n\nexport default function AddNodeDropdown({ namespace }) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const { currentUser } = useCurrentUser();\n const navigate = useNavigate();\n const ns = resolvePersonalNamespace(namespace, currentUser?.username);\n const isPersonalFallback =\n (!namespace || namespace === 'default') && currentUser?.username;\n\n const goTo = path => async e => {\n e.preventDefault();\n if (isPersonalFallback) {\n // Create the personal namespace if it doesn't exist yet (idempotent on the server)\n try {\n await djClient.addNamespace(ns);\n } catch (err) {\n console.error('Failed to ensure namespace exists:', err);\n }\n }\n navigate(path);\n };\n\n return (\n <span\n className=\"menu-link\"\n style={{\n display: 'inline-flex',\n alignItems: 'center',\n margin: 0,\n width: '130px',\n }}\n >\n <span className=\"menu-title\">\n <div className=\"dropdown\">\n <span className=\"add_node\">+ Add Node</span>\n <div className=\"dropdown-content add-node-dropdown-content\">\n <a href={`/create/source`}>\n <div className=\"node_type__source node_type_creation_heading\">\n Register Table\n </div>\n </a>\n <a\n href={`/create/transform/${ns}`}\n onClick={goTo(`/create/transform/${ns}`)}\n >\n <div className=\"node_type__transform node_type_creation_heading\">\n Transform\n </div>\n </a>\n <a\n href={`/create/metric/${ns}`}\n onClick={goTo(`/create/metric/${ns}`)}\n >\n <div className=\"node_type__metric node_type_creation_heading\">\n Metric\n </div>\n </a>\n <a\n href={`/create/dimension/${ns}`}\n onClick={goTo(`/create/dimension/${ns}`)}\n >\n <div className=\"node_type__dimension node_type_creation_heading\">\n Dimension\n </div>\n </a>\n <a href={`/create/tag`}>\n <div className=\"entity__tag node_type_creation_heading\">Tag</div>\n </a>\n <a href={`/create/cube/${ns}`} onClick={goTo(`/create/cube/${ns}`)}>\n <div className=\"node_type__cube node_type_creation_heading\">\n Cube\n </div>\n </a>\n </div>\n </div>\n </span>\n </span>\n );\n}\n","import Select, { components } from 'react-select';\n\n// react-select renders a DOM node per option with no virtualization, so a large\n// list (e.g. all users) makes the menu sluggish to open/scroll. Cap how many\n// options are rendered at once; react-select still filters the full list as the\n// user types, so any option remains reachable by searching.\nconst MAX_RENDERED_OPTIONS = 100;\n\nconst WindowedMenuList = props => {\n const { children } = props;\n if (Array.isArray(children) && children.length > MAX_RENDERED_OPTIONS) {\n return (\n <components.MenuList {...props}>\n {children.slice(0, MAX_RENDERED_OPTIONS)}\n <div\n style={{\n padding: '6px 12px',\n fontSize: '11px',\n color: '#888',\n }}\n >\n Showing first {MAX_RENDERED_OPTIONS} of {children.length}. Type to\n narrow…\n </div>\n </components.MenuList>\n );\n }\n return <components.MenuList {...props}>{children}</components.MenuList>;\n};\n\n// Compact select with label above - saves horizontal space\nexport default function CompactSelect({\n label,\n name,\n options,\n value,\n onChange,\n isMulti = false,\n isClearable = true,\n placeholder = 'Select...',\n minWidth = '100px',\n flex = 1,\n isLoading = false,\n testId = null,\n formatOptionLabel = undefined,\n onMenuOpen = undefined,\n}) {\n // For single select, find the matching option\n // For multi select, filter to matching options\n const selectedValue = isMulti\n ? value?.length\n ? options.filter(o => value.includes(o.value))\n : []\n : value\n ? options.find(o => o.value === value)\n : null;\n\n return (\n <div\n style={{\n display: 'flex',\n flexDirection: 'column',\n gap: '2px',\n flex,\n minWidth,\n }}\n data-testid={testId}\n >\n <label\n style={{\n fontSize: '10px',\n fontWeight: '600',\n color: '#666',\n textTransform: 'uppercase',\n letterSpacing: '0.5px',\n }}\n >\n {label}\n </label>\n <Select\n name={name}\n isClearable={isClearable}\n isMulti={isMulti}\n isLoading={isLoading}\n placeholder={placeholder}\n onChange={onChange}\n onMenuOpen={onMenuOpen}\n value={selectedValue}\n formatOptionLabel={formatOptionLabel}\n components={{ MenuList: WindowedMenuList }}\n styles={{\n control: base => ({\n ...base,\n minHeight: '32px',\n height: isMulti ? 'auto' : '32px',\n fontSize: '12px',\n backgroundColor: 'white',\n }),\n valueContainer: base => ({\n ...base,\n padding: '0 6px',\n }),\n input: base => ({\n ...base,\n margin: 0,\n padding: 0,\n }),\n indicatorSeparator: () => ({\n display: 'none',\n }),\n dropdownIndicator: base => ({\n ...base,\n padding: '4px',\n }),\n clearIndicator: base => ({\n ...base,\n padding: '4px',\n }),\n option: base => ({\n ...base,\n fontSize: '12px',\n padding: '6px 10px',\n }),\n multiValue: base => ({\n ...base,\n fontSize: '11px',\n }),\n }}\n options={options}\n />\n </div>\n );\n}\n","import React from 'react';\n\n// A clean chevron that points right when collapsed and rotates down when open.\n// Shared by the rail folder tree and the rail's collapsible group headings so\n// expand/collapse looks the same everywhere. Color comes from `currentColor`.\nexport default function ChevronIcon({ open = false, size = 12 }) {\n return (\n <svg\n width={size}\n height={size}\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth=\"3\"\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n style={{\n transform: open ? 'rotate(90deg)' : 'none',\n transition: 'transform 0.12s ease',\n }}\n >\n <polyline points=\"9 6 15 12 9 18\" />\n </svg>\n );\n}\n","// Per-device pinned namespaces, backed by localStorage (no backend).\nconst PINNED_KEY = 'dj.ns.pinned';\n\nfunction read(key) {\n try {\n const v = JSON.parse(localStorage.getItem(key));\n return Array.isArray(v) ? v.filter(x => typeof x === 'string') : [];\n } catch {\n return [];\n }\n}\n\nfunction write(key, list) {\n try {\n localStorage.setItem(key, JSON.stringify(list));\n } catch {\n /* ignore quota / unavailable storage */\n }\n}\n\nexport function getPinned() {\n return read(PINNED_KEY);\n}\n\nexport function togglePinned(ns) {\n const cur = read(PINNED_KEY);\n const next = cur.includes(ns) ? cur.filter(x => x !== ns) : [...cur, ns];\n write(PINNED_KEY, next);\n return next;\n}\n\nexport { PINNED_KEY };\n","import React, { useState } from 'react';\nimport ChevronIcon from '../../icons/ChevronIcon';\n\n// Recursive folder tree for the namespace rail. `nodes` are hierarchy nodes\n// ({ namespace, path, children }) — the descendants of the current namespace.\n// Clicking the chevron expands/collapses that level in place (no navigation);\n// clicking the name navigates into it (onSelect(path)). Collapsed beyond the\n// top level so a deep tree only grows on demand.\nfunction FolderTreeRows({ nodes, depth, expanded, onToggle, onSelect }) {\n return nodes.map(node => {\n const hasChildren = node.children?.length > 0;\n const isOpen = expanded.has(node.path);\n return (\n <React.Fragment key={node.path}>\n <div\n className=\"dj-ns-nav-item\"\n role=\"button\"\n tabIndex={0}\n title={node.path}\n style={{ paddingLeft: `${depth * 14}px` }}\n onClick={() => onSelect(node.path)}\n onKeyDown={e => {\n if (e.key === 'Enter') onSelect(node.path);\n }}\n >\n {hasChildren ? (\n <button\n type=\"button\"\n aria-label={isOpen ? 'Collapse' : 'Expand'}\n aria-expanded={isOpen}\n onClick={e => {\n e.stopPropagation();\n onToggle(node.path);\n }}\n style={{\n width: '18px',\n minWidth: '18px',\n flexShrink: 0,\n padding: 0,\n margin: 0,\n border: 'none',\n background: 'none',\n cursor: 'pointer',\n color: '#64748b',\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n }}\n >\n <ChevronIcon open={isOpen} />\n </button>\n ) : (\n <span style={{ width: '18px', minWidth: '18px', flexShrink: 0 }} />\n )}\n <span className=\"dj-ns-nav-name\">{node.namespace}</span>\n </div>\n {hasChildren && isOpen ? (\n <FolderTreeRows\n nodes={node.children}\n depth={depth + 1}\n expanded={expanded}\n onToggle={onToggle}\n onSelect={onSelect}\n />\n ) : null}\n </React.Fragment>\n );\n });\n}\n\n// Rail folder tree rooted at the current namespace's children. Remount it\n// (via a `key` on the current path) to reset expansion when the root changes.\nexport default function FolderTree({ folders, onSelect }) {\n const [expanded, setExpanded] = useState(() => new Set());\n if (!folders || folders.length === 0) return null;\n const onToggle = path =>\n setExpanded(prev => {\n const next = new Set(prev);\n if (next.has(path)) {\n next.delete(path);\n } else {\n next.add(path);\n }\n return next;\n });\n return (\n <div className=\"dj-ns-folder-nav\">\n <div className=\"dj-ns-tree-heading\">Folders</div>\n <FolderTreeRows\n nodes={folders}\n depth={0}\n expanded={expanded}\n onToggle={onToggle}\n onSelect={onSelect}\n />\n </div>\n );\n}\n","import React, { useState } from 'react';\n\n// Inline \"new folder\" affordance for the rail folder tree: a subtle button that\n// expands into a text field for the leaf name. Enter creates `<parent>.<name>`\n// (via onCreate), Esc / empty-blur cancels. Only rendered for non-git-backed\n// namespaces (namespaces under git control are managed via git, not the UI).\nexport default function NewSubNamespace({ parent, onCreate }) {\n const [adding, setAdding] = useState(false);\n const [name, setName] = useState('');\n const [error, setError] = useState(null);\n\n // Sanitize to a single namespace segment for the create call.\n const segment = name.trim().replace(/[^a-zA-Z0-9_]/g, '_');\n\n const reset = () => {\n setAdding(false);\n setName('');\n setError(null);\n };\n\n const submit = async () => {\n if (!segment) {\n reset();\n return;\n }\n const res = await onCreate(`${parent}.${segment}`);\n if (res?._error) {\n setError(res.message || 'Failed to create namespace');\n } else {\n reset();\n }\n };\n\n if (!adding) {\n // Match the folder rows (dj-ns-nav-item): same 15px size/padding, with the\n // + sitting in the same column the folder chevrons occupy. Muted color marks\n // it as a secondary \"add\" affordance.\n return (\n <button\n type=\"button\"\n className=\"dj-ns-nav-item\"\n onClick={() => setAdding(true)}\n style={{ paddingLeft: 0, color: '#64748b' }}\n >\n <span\n style={{\n width: '18px',\n minWidth: '18px',\n flexShrink: 0,\n display: 'inline-flex',\n alignItems: 'center',\n justifyContent: 'center',\n fontSize: '16px',\n lineHeight: 1,\n }}\n >\n +\n </span>\n <span className=\"dj-ns-nav-name\">New folder</span>\n </button>\n );\n }\n\n return (\n <div style={{ marginTop: '4px', paddingLeft: '18px', paddingRight: '8px' }}>\n <input\n autoFocus\n type=\"text\"\n value={name}\n onChange={e => setName(e.target.value)}\n onKeyDown={e => {\n if (e.key === 'Enter') submit();\n else if (e.key === 'Escape') reset();\n }}\n onBlur={() => {\n if (!name.trim()) reset();\n }}\n placeholder=\"Folder name\"\n aria-label=\"New sub-namespace name\"\n style={{\n width: '100%',\n boxSizing: 'border-box',\n padding: '6px 8px',\n fontSize: '15px',\n border: '1px solid #cbd5e1',\n borderRadius: '6px',\n outline: 'none',\n }}\n />\n {error ? (\n <div style={{ marginTop: '4px', fontSize: '12px', color: '#b91c1c' }}>\n {error}\n </div>\n ) : null}\n </div>\n );\n}\n","import React, { useState } from 'react';\nimport ChevronIcon from '../../icons/ChevronIcon';\nimport {\n buildNamespaceOptions,\n searchNamespaces,\n immediateChildren,\n} from './namespaceOptions';\nimport { getPinned, togglePinned } from './namespaceShortcuts';\nimport FolderTree from './FolderTree';\nimport NewSubNamespace from './NewSubNamespace';\n\n// Git context for a selected namespace, derived from the raw namespace list.\nfunction gitContext(gitByNs, ns) {\n const g = gitByNs[ns];\n if (!g) return null;\n if (g.__typename === 'GitRootConfig') {\n return {\n root: ns,\n defaultBranch: g.defaultBranch,\n activeBranch: g.defaultBranch,\n isRoot: true,\n };\n }\n if (g.__typename === 'GitBranchConfig') {\n return {\n root: g.parentNamespace,\n defaultBranch: g.root?.defaultBranch,\n activeBranch: g.branch,\n isRoot: false,\n };\n }\n return null;\n}\n\nexport default function NamespaceNav({\n namespaces,\n hierarchy,\n currentNamespace,\n gitRoots,\n onSelect,\n canCreateNamespace = false,\n onCreateNamespace,\n}) {\n const [collapsed, setCollapsed] = useState({ 'Top-level': true });\n const [pinned, setPinned] = useState(() => getPinned());\n // Filter only exists in the no-namespace (\"All namespaces\") view, where there's\n // no current scope to confuse it with. Once a namespace is selected, jumping\n // elsewhere is done via the header's namespace switcher.\n const [filter, setFilter] = useState('');\n\n const isFiltering = filter.trim() !== '';\n const matches = isFiltering ? searchNamespaces(namespaces || [], filter) : [];\n const showList = !currentNamespace;\n const groups = showList ? buildNamespaceOptions(namespaces || []) : [];\n\n const gitByNs = {};\n for (const ns of namespaces || []) {\n gitByNs[ns.namespace] = ns.git;\n }\n const pinnedSet = new Set(pinned);\n\n const isOpen = label => !collapsed[label];\n const toggleGroup = label =>\n setCollapsed(c => ({ ...c, [label]: !c[label] }));\n\n const onPin = (e, ns) => {\n e.stopPropagation();\n setPinned(togglePinned(ns));\n };\n\n const renderRow = (ns, keyPrefix) => (\n <div\n key={`${keyPrefix}-${ns}`}\n className=\"dj-ns-nav-item\"\n role=\"button\"\n tabIndex={0}\n title={ns}\n onClick={() => onSelect(ns)}\n onKeyDown={e => {\n if (e.key === 'Enter') onSelect(ns);\n }}\n >\n <span className=\"dj-ns-nav-name\">{ns}</span>\n <button\n type=\"button\"\n className={`dj-ns-star${pinnedSet.has(ns) ? ' pinned' : ''}`}\n aria-label={`${pinnedSet.has(ns) ? 'Unpin' : 'Pin'} ${ns}`}\n aria-pressed={pinnedSet.has(ns)}\n onClick={e => onPin(e, ns)}\n >\n {pinnedSet.has(ns) ? '★' : '☆'}\n </button>\n </div>\n );\n\n // One uniform collapsible section used for Pinned and the git groups.\n const renderGroup = (label, names, keyPrefix) => {\n const open = isOpen(label);\n return (\n <div key={keyPrefix}>\n <button\n type=\"button\"\n className=\"dj-ns-group-heading\"\n aria-expanded={open}\n onClick={() => toggleGroup(label)}\n >\n <span className=\"dj-ns-chevron\">\n <ChevronIcon open={open} />\n </span>\n <span className=\"dj-ns-group-label\">{label}</span>\n <span className=\"dj-ns-group-count\">{names.length}</span>\n </button>\n {open ? names.map(ns => renderRow(ns, keyPrefix)) : null}\n </div>\n );\n };\n\n // Selected (subtree) state — ctx is still needed for subtreePath (git-root folders).\n const ctx = currentNamespace ? gitContext(gitByNs, currentNamespace) : null;\n const subtreePath =\n ctx?.isRoot && ctx.defaultBranch\n ? `${ctx.root}.${ctx.defaultBranch}`\n : currentNamespace;\n\n return (\n <div>\n {showList ? (\n <>\n <div className=\"dj-ns-filter\">\n <input\n type=\"text\"\n className=\"dj-ns-filter-input\"\n value={filter}\n onChange={e => setFilter(e.target.value)}\n placeholder=\"Filter namespaces…\"\n aria-label=\"Filter namespaces\"\n />\n {isFiltering ? (\n <button\n type=\"button\"\n className=\"dj-ns-filter-clear\"\n aria-label=\"Clear filter\"\n title=\"Clear filter\"\n onClick={() => setFilter('')}\n >\n ✕\n </button>\n ) : null}\n </div>\n {isFiltering ? (\n matches.length === 0 ? (\n <div\n style={{ padding: '6px', color: '#64748b', fontSize: '12px' }}\n >\n No namespaces match \"{filter.trim()}\".\n </div>\n ) : (\n renderGroup('Matches', matches, 'matches')\n )\n ) : (\n <>\n {pinned.length > 0 ? renderGroup('Pinned', pinned, 'pin') : null}\n {groups.map(group =>\n renderGroup(\n group.label,\n group.options.map(o => o.value),\n `grp-${group.label}`,\n ),\n )}\n </>\n )}\n </>\n ) : (\n // Folder navigation: the current namespace's sub-namespace tree. Chevron\n // expands a level in place; clicking a name drills in. Going up is handled\n // by the header breadcrumb; switching namespace by the filter box\n // (no-namespace view) or the header switcher. Keyed by the current path so\n // expansion resets when the tree re-roots.\n <>\n <FolderTree\n key={subtreePath}\n folders={immediateChildren(hierarchy || [], subtreePath)}\n onSelect={onSelect}\n />\n {canCreateNamespace ? (\n <NewSubNamespace\n parent={subtreePath}\n onCreate={onCreateNamespace}\n />\n ) : null}\n </>\n )}\n </div>\n );\n}\n","// Canonical node-type ordering + badge colors for the NamespacePage views.\n// Single source of truth shared by the namespace landing preview (index.jsx) and the\n// rail's per-namespace type summary (NamespaceTypeSummary.jsx) so the order and colors\n// can't drift between them.\n\nexport const NODE_TYPE_ORDER = [\n 'metric',\n 'cube',\n 'dimension',\n 'transform',\n 'source',\n];\n\nexport const NODE_TYPE_COLORS = {\n metric: { bg: '#fad7dd', color: '#a2283e' },\n cube: { bg: '#dbafff', color: '#580076' },\n dimension: { bg: '#ffefd0', color: '#a96621' },\n transform: { bg: '#ccefff', color: '#0063b4' },\n source: { bg: '#ccf7e5', color: '#00b368' },\n};\n","import { useParams, useSearchParams, useNavigate } from 'react-router-dom';\nimport {\n useContext,\n useEffect,\n useState,\n useCallback,\n useRef,\n useMemo,\n} from 'react';\nimport DJClientContext from '../../providers/djclient';\nimport { useCurrentUser } from '../../providers/UserProvider';\nimport AddNodeDropdown from '../../components/AddNodeDropdown';\nimport NodeListActions from '../../components/NodeListActions';\nimport NamespaceHeader from '../../components/NamespaceHeader';\nimport SplitFilter from '../../components/SplitFilter';\nimport Tooltip from '../../components/Tooltip';\nimport {\n secondaryButtonStyle,\n onSecondaryHover,\n onSecondaryOut,\n} from '../../components/buttonStyles';\nimport LoadingIcon from '../../icons/LoadingIcon';\nimport CompactSelect from './CompactSelect';\nimport NamespaceNav from './NamespaceNav';\nimport { isHiddenNamespace } from './namespaceOptions';\nimport { NODE_TYPE_ORDER, NODE_TYPE_COLORS } from './nodeTypes';\nimport { getDJUrl } from '../../services/DJService';\n\nimport 'styles/node-list.css';\nimport 'styles/sorted-table.css';\n\nconst AVATAR_COLORS = [\n ['#dbeafe', '#1e40af'],\n ['#dcfce7', '#166534'],\n ['#fef3c7', '#92400e'],\n ['#fce7f3', '#9d174d'],\n ['#ede9fe', '#5b21b6'],\n ['#ffedd5', '#9a3412'],\n ['#fee2e2', '#991b1b'],\n ['#d1fae5', '#065f46'],\n];\n\nfunction avatarColorIndex(username) {\n let hash = 0;\n for (let i = 0; i < username.length; i++) {\n hash = (hash * 31 + username.charCodeAt(i)) >>> 0;\n }\n return hash % AVATAR_COLORS.length;\n}\n\nconst FIELD_CONFIG = {\n name: { label: 'Name' },\n displayName: { label: 'Display name' },\n type: { label: 'Type' },\n status: {\n label: 'Validation',\n tooltip: 'Whether this node validates successfully.',\n },\n mode: {\n label: 'Publish state',\n tooltip: 'Whether this node is published or still a draft.',\n },\n owners: { label: 'Owners' },\n updatedAt: { label: 'Updated' },\n};\n\nconst CELL_STYLE = {\n padding: '8px 16px',\n};\n\nconst MIDDLE_CELL_STYLE = {\n ...CELL_STYLE,\n verticalAlign: 'middle',\n};\n\nconst NODE_TYPE_DESCRIPTIONS = {\n CUBE: 'A curated set of metrics and dimensions for querying together.',\n DIMENSION: 'Descriptive attributes used to group, filter, and join metrics.',\n METRIC: 'A reusable business calculation or measure.',\n SOURCE: 'A physical table registered in DJ as a source node.',\n TRANSFORM: 'A SQL-defined node derived from sources or other DJ nodes.',\n};\n\nfunction splitNodeName(nodeName) {\n const lastDot = nodeName.lastIndexOf('.');\n if (lastDot === -1) {\n return { leafName: nodeName, namespacePath: '' };\n }\n return {\n leafName: nodeName.slice(lastDot + 1),\n namespacePath: nodeName.slice(0, lastDot),\n };\n}\n\nfunction nodeState(current) {\n const isPublished = current.mode === 'PUBLISHED';\n const isValid = current.status === 'VALID';\n return {\n isPublished,\n isValid,\n publishLabel: isPublished ? 'Published' : 'Draft',\n validationLabel: isValid ? 'Valid' : 'Needs fixes',\n publishTooltip: isPublished\n ? 'This version is published and expected to be usable by downstream nodes.'\n : 'This version is a draft and may still be incomplete.',\n validationTooltip: isValid\n ? 'This node validates successfully.'\n : 'This node has validation errors. Open it to see what needs to be fixed.',\n needsAttention: !isPublished || !isValid,\n borderColor: isValid && isPublished ? '#28a745' : '#d39e00',\n backgroundColor: isValid && isPublished ? '#eaf7ee' : '#fff7df',\n color: isValid && isPublished ? '#1f7a3a' : '#8a5b00',\n };\n}\n\nfunction exactDateTime(value) {\n return new Date(value).toLocaleString('en-us', {\n year: 'numeric',\n month: 'short',\n day: 'numeric',\n hour: 'numeric',\n minute: '2-digit',\n });\n}\n\nfunction relativeDate(value) {\n const updatedAt = new Date(value);\n const diffMs = Date.now() - updatedAt.getTime();\n const absMs = Math.abs(diffMs);\n const units = [\n ['year', 365 * 24 * 60 * 60 * 1000],\n ['month', 30 * 24 * 60 * 60 * 1000],\n ['week', 7 * 24 * 60 * 60 * 1000],\n ['day', 24 * 60 * 60 * 1000],\n ['hour', 60 * 60 * 1000],\n ['minute', 60 * 1000],\n ];\n\n for (const [unit, ms] of units) {\n if (absMs >= ms) {\n const valueForUnit = Math.round(absMs / ms);\n return `${valueForUnit} ${unit}${valueForUnit === 1 ? '' : 's'} ${\n diffMs >= 0 ? 'ago' : 'from now'\n }`;\n }\n }\n return 'just now';\n}\n\nexport function NamespacePage() {\n const ASC = 'ascending';\n const DESC = 'descending';\n\n const fields = [\n 'name',\n 'displayName',\n 'type',\n 'status',\n 'mode',\n 'owners',\n 'updatedAt',\n ];\n\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const { currentUser } = useCurrentUser();\n var { namespace } = useParams();\n const navigate = useNavigate();\n const [searchParams, setSearchParams] = useSearchParams();\n\n // Data for select options. These populate filter dropdowns only, so they're\n // fetched lazily on first dropdown open instead of on page load — except when a\n // users/tags filter is already active from the URL, where we need the options to\n // render the selected label.\n const [users, setUsers] = useState([]);\n const [tags, setTags] = useState([]);\n const [usersLoading, setUsersLoading] = useState(false);\n const [tagsLoading, setTagsLoading] = useState(false);\n const usersRequested = useRef(false);\n const tagsRequested = useRef(false);\n\n const ensureUsers = useCallback(async () => {\n if (usersRequested.current) return;\n usersRequested.current = true;\n setUsersLoading(true);\n try {\n const data = await djClient.users();\n setUsers(data || []);\n } finally {\n setUsersLoading(false);\n }\n }, [djClient]);\n\n const ensureTags = useCallback(async () => {\n if (tagsRequested.current) return;\n tagsRequested.current = true;\n setTagsLoading(true);\n try {\n const data = await djClient.listTags();\n setTags(data || []);\n } finally {\n setTagsLoading(false);\n }\n }, [djClient]);\n\n // Eagerly fetch only what an already-applied filter needs to render its label.\n useEffect(() => {\n if (searchParams.get('editedBy') || searchParams.get('ownedBy')) {\n ensureUsers().catch(console.error);\n }\n if (searchParams.get('tags')) {\n ensureTags().catch(console.error);\n }\n }, [searchParams, ensureUsers, ensureTags]);\n\n // Parse all filters from URL\n const getFiltersFromUrl = useCallback(\n () => ({\n node_type: searchParams.get('type') || '',\n tags: searchParams.get('tags') ? searchParams.get('tags').split(',') : [],\n edited_by: searchParams.get('editedBy') || '',\n mode: searchParams.get('mode') || '',\n ownedBy: searchParams.get('ownedBy') || '',\n statuses: searchParams.get('statuses') || '',\n missingDescription: searchParams.get('missingDescription') === 'true',\n hasMaterialization: searchParams.get('hasMaterialization') === 'true',\n orphanedDimension: searchParams.get('orphanedDimension') === 'true',\n }),\n [searchParams],\n );\n\n const [filters, setFilters] = useState(getFiltersFromUrl);\n const [moreFiltersOpen, setMoreFiltersOpen] = useState(false);\n\n // Sync filters state when the URL changes. `filters` is already seeded from the\n // URL via useState, so we skip the initial run — otherwise it would replace\n // filters with an equal-but-new object and trigger a redundant node refetch.\n const didSyncFilters = useRef(false);\n useEffect(() => {\n if (!didSyncFilters.current) {\n didSyncFilters.current = true;\n return;\n }\n setFilters(getFiltersFromUrl());\n }, [searchParams, getFiltersFromUrl]);\n\n // Update URL when filters change\n const updateFilters = useCallback(\n newFilters => {\n const params = new URLSearchParams();\n\n if (newFilters.node_type) params.set('type', newFilters.node_type);\n if (newFilters.tags?.length)\n params.set('tags', newFilters.tags.join(','));\n if (newFilters.edited_by) params.set('editedBy', newFilters.edited_by);\n if (newFilters.mode) params.set('mode', newFilters.mode);\n if (newFilters.ownedBy) params.set('ownedBy', newFilters.ownedBy);\n if (newFilters.statuses) params.set('statuses', newFilters.statuses);\n if (newFilters.missingDescription)\n params.set('missingDescription', 'true');\n if (newFilters.hasMaterialization)\n params.set('hasMaterialization', 'true');\n if (newFilters.orphanedDimension) params.set('orphanedDimension', 'true');\n\n setSearchParams(params);\n },\n [setSearchParams],\n );\n\n const clearAllFilters = () => {\n setSearchParams(new URLSearchParams());\n };\n\n // Check if any filters are active\n const hasActiveFilters =\n filters.node_type ||\n filters.tags?.length ||\n filters.edited_by ||\n filters.mode ||\n filters.ownedBy ||\n filters.statuses ||\n filters.missingDescription ||\n filters.hasMaterialization ||\n filters.orphanedDimension;\n\n // Quick presets\n const presets = [\n {\n id: 'my-nodes',\n label: 'My Nodes',\n filters: { ownedBy: currentUser?.username },\n },\n {\n id: 'needs-attention',\n label: 'Needs Attention',\n filters: { ownedBy: currentUser?.username, statuses: 'INVALID' },\n },\n {\n id: 'drafts',\n label: 'Drafts',\n filters: { ownedBy: currentUser?.username, mode: 'draft' },\n },\n ];\n\n const applyPreset = preset => {\n const newFilters = {\n node_type: '',\n tags: [],\n edited_by: '',\n mode: preset.filters.mode || '',\n ownedBy: preset.filters.ownedBy || '',\n statuses: preset.filters.statuses || '',\n missingDescription: preset.filters.missingDescription || false,\n hasMaterialization: preset.filters.hasMaterialization || false,\n orphanedDimension: preset.filters.orphanedDimension || false,\n };\n updateFilters(newFilters);\n };\n\n // Check if a preset is active\n const isPresetActive = preset => {\n const pf = preset.filters;\n return (\n (pf.ownedBy || '') === (filters.ownedBy || '') &&\n (pf.statuses || '') === (filters.statuses || '') &&\n (pf.mode || '') === (filters.mode || '') &&\n !filters.node_type &&\n !filters.tags?.length &&\n !filters.edited_by &&\n !filters.missingDescription &&\n !filters.hasMaterialization &&\n !filters.orphanedDimension\n );\n };\n\n const [state, setState] = useState({\n namespace: namespace ? namespace : '',\n nodes: [],\n });\n const [retrieved, setRetrieved] = useState(false);\n\n const [namespaceHierarchy, setNamespaceHierarchy] = useState([]);\n const [rawNamespaces, setRawNamespaces] = useState([]);\n const [gitRoots, setGitRoots] = useState(new Set());\n // Use undefined to indicate \"not yet loaded\", null means \"loaded but no config\"\n const [gitConfig, setGitConfig] = useState(undefined);\n\n const [sortConfig, setSortConfig] = useState({\n key: 'updatedAt',\n direction: DESC,\n });\n\n const [before, setBefore] = useState(null);\n const [after, setAfter] = useState(null);\n const [prevCursor, setPrevCursor] = useState(true);\n const [nextCursor, setNextCursor] = useState(true);\n\n const [hasNextPage, setHasNextPage] = useState(true);\n const [hasPrevPage, setHasPrevPage] = useState(true);\n\n const [nodeSearch, setNodeSearch] = useState('');\n const [debouncedSearch, setDebouncedSearch] = useState('');\n useEffect(() => {\n const t = setTimeout(() => setDebouncedSearch(nodeSearch.trim()), 300);\n return () => clearTimeout(t);\n }, [nodeSearch]);\n // Changing the search term restarts paging from the first page; a cursor from\n // a previous page would otherwise be sent with the new query.\n useEffect(() => {\n setBefore(null);\n setAfter(null);\n }, [debouncedSearch]);\n\n const [typeCounts, setTypeCounts] = useState(null);\n // undefined until NamespaceHeader reports the read-only verdict, so Add Node stays hidden until known\n const [headerReadOnly, setHeaderReadOnly] = useState(undefined);\n\n // Only show edit/add controls once git config has loaded and namespace is not git-only\n const gitConfigLoaded = gitConfig !== undefined;\n // Descendants inherit github_repo_path via cascade, so compare against\n // git_root_namespace to know if this namespace IS the git root.\n const isGitRoot =\n gitConfigLoaded &&\n !!gitConfig?.github_repo_path &&\n gitConfig?.git_root_namespace === namespace;\n // Node-edit controls follow the single read-only verdict from NamespaceHeader\n // (`headerReadOnly`), which correctly treats the default branch, 1:1/root git\n // namespaces, git_only, and git-deployed non-feature content as read-only\n // while leaving feature branches (and plain namespaces) editable. It's\n // `undefined` until the header reports, so controls stay hidden until the\n // verdict is known (no flash). Deriving a second, shape-based verdict here is\n // what let the default branch (shape 'branch') wrongly show edit controls.\n const showEditControls = headerReadOnly === false;\n // Sub-namespaces can be created from the rail only for plain (non-git-backed)\n // namespaces; git-backed ones are managed via git, not the UI. The git config\n // endpoint returns an object with null fields (not null) for non-git\n // namespaces, so check the actual git markers — a repo (root or cascaded\n // descendant) or a branch namespace.\n const isGitBacked = !!(\n gitConfig?.github_repo_path || gitConfig?.branch_namespace\n );\n const canCreateNamespace = gitConfigLoaded && !isGitBacked;\n const createSubNamespace = async fullNamespace => {\n const response = await djClient.addNamespace(fullNamespace);\n if (response.status === 200 || response.status === 201) {\n // The cached namespace list is now stale — refetch it on next mount.\n djClient.invalidateNamespacesCache?.();\n navigate(`/namespaces/${fullNamespace}`);\n return {};\n }\n return {\n _error: true,\n message: response.json?.message || 'Failed to create namespace',\n };\n };\n // Distinguish a *child-spawning* git root (owns the repo, has no branch of its\n // own — its nodes live on `<root>.<default_branch>`) from a *flat* git-backed\n // namespace (has its own `git_branch`, e.g. tracks `main` directly, with no\n // `<ns>.<branch>` children). Only the former is browsed via / redirected to its\n // default branch; a flat namespace is its own branch and must stay put.\n const isChildSpawningRoot = isGitRoot && !gitConfig?.git_branch;\n\n // A child-spawning root has no nodes of its own — they live on its default\n // branch — so the node table browses `<root>.<default_branch>`. A flat\n // namespace browses itself.\n const tableNamespace =\n isChildSpawningRoot && gitConfig?.default_branch\n ? `${namespace}.${gitConfig.default_branch}`\n : namespace;\n\n // A child-spawning root isn't a browsable branch — redirect to its default\n // branch so the URL is the branch (giving the breadcrumb its branch switcher)\n // and everything is scoped consistently. A flat git-backed namespace is NOT\n // redirected: it *is* the branch, and `<ns>.<default_branch>` doesn't exist.\n useEffect(() => {\n if (isChildSpawningRoot && gitConfig?.default_branch) {\n navigate(`/namespaces/${namespace}.${gitConfig.default_branch}`, {\n replace: true,\n });\n }\n }, [isChildSpawningRoot, gitConfig, namespace, navigate]);\n\n // Per-type node counts (recursive) for the current namespace, shown inline in\n // the TYPE filter options (e.g. \"Metric (342)\").\n useEffect(() => {\n // Wait until git config resolves so tableNamespace is final — otherwise a\n // git root fetches its provisional namespace, then refetches after redirect.\n if (!gitConfigLoaded) return;\n if (isGitRoot && gitConfig?.default_branch) return;\n let cancelled = false;\n if (!tableNamespace) {\n setTypeCounts(null);\n return;\n }\n djClient\n .nodeTypeCounts(tableNamespace, NODE_TYPE_ORDER)\n .then(byType => {\n if (!cancelled) setTypeCounts(byType);\n })\n .catch(() => {\n if (!cancelled) setTypeCounts(null);\n });\n return () => {\n cancelled = true;\n };\n }, [djClient, tableNamespace, gitConfigLoaded, isGitRoot, gitConfig]);\n\n const requestSort = key => {\n let direction = ASC;\n if (sortConfig.key === key && sortConfig.direction === ASC) {\n direction = DESC;\n }\n if (sortConfig.key !== key || sortConfig.direction !== direction) {\n setSortConfig({ key, direction });\n }\n };\n\n const getClassNamesFor = name => {\n if (sortConfig.key === name) {\n return sortConfig.direction;\n }\n return undefined;\n };\n\n const createNamespaceHierarchy = namespaceList => {\n const hierarchy = [];\n // Map lookups (path -> node's children map) keep insertion O(n · depth); we sort\n // each level once at the end rather than on every insert (which was O(n² log n)).\n const rootMap = new Map();\n const childMaps = new Map();\n\n for (const item of namespaceList) {\n const segments = item.namespace.split('.');\n let currentLevel = hierarchy;\n let levelMap = rootMap;\n let path = '';\n for (const ns of segments) {\n path += ns;\n let node = levelMap.get(ns);\n if (!node) {\n node = { namespace: ns, children: [], path };\n currentLevel.push(node);\n levelMap.set(ns, node);\n childMaps.set(path, new Map());\n }\n currentLevel = node.children;\n levelMap = childMaps.get(path);\n path += '.';\n }\n }\n\n const sortLevel = level => {\n level.sort((a, b) => a.namespace.localeCompare(b.namespace));\n level.forEach(node => sortLevel(node.children));\n };\n sortLevel(hierarchy);\n return hierarchy;\n };\n\n useEffect(() => {\n const fetchData = async () => {\n const all = await djClient.listNamespacesWithGit();\n // Hide system/internal scratch namespaces from all browse navigation\n // (rail list, header switcher, folders).\n const namespaces = all.filter(ns => !isHiddenNamespace(ns.namespace));\n setRawNamespaces(namespaces);\n const hierarchy = createNamespaceHierarchy(namespaces);\n setNamespaceHierarchy(hierarchy);\n\n const roots = new Set(\n namespaces\n .filter(ns => ns.git?.__typename === 'GitRootConfig')\n .map(ns => ns.namespace),\n );\n setGitRoots(roots);\n };\n fetchData().catch(console.error);\n }, [djClient]);\n\n useEffect(() => {\n // Wait until git config resolves so tableNamespace is final — otherwise a\n // git root fetches its provisional namespace, then refetches after redirect.\n if (!gitConfigLoaded) return;\n if (isGitRoot && gitConfig?.default_branch) return;\n const fetchData = async () => {\n setRetrieved(false);\n\n // Build extended filters for API\n const extendedFilters = {\n ownedBy: filters.ownedBy || null,\n statuses: filters.statuses ? [filters.statuses] : null,\n missingDescription: filters.missingDescription,\n hasMaterialization: filters.hasMaterialization,\n orphanedDimension: filters.orphanedDimension,\n // The table shows every node under this namespace (all descendants),\n // matching the rail's per-type counts. The rail's Folders drill DOWN to\n // re-scope; search narrows within the current scope.\n search: debouncedSearch || null,\n };\n\n const nodes = await djClient.listNodesForLanding(\n tableNamespace,\n filters.node_type ? [filters.node_type.toUpperCase()] : [],\n filters.tags,\n filters.edited_by,\n before,\n after,\n 50,\n sortConfig,\n filters.mode ? filters.mode.toUpperCase() : null,\n extendedFilters,\n );\n\n setState({\n namespace: namespace,\n nodes: nodes.data\n ? nodes.data.findNodesPaginated.edges.map(n => n.node)\n : [],\n });\n if (nodes.data) {\n setPrevCursor(\n nodes.data ? nodes.data.findNodesPaginated.pageInfo.startCursor : '',\n );\n setNextCursor(\n nodes.data ? nodes.data.findNodesPaginated.pageInfo.endCursor : '',\n );\n setHasPrevPage(\n nodes.data\n ? nodes.data.findNodesPaginated.pageInfo.hasPrevPage\n : false,\n );\n setHasNextPage(\n nodes.data\n ? nodes.data.findNodesPaginated.pageInfo.hasNextPage\n : false,\n );\n }\n setRetrieved(true);\n };\n fetchData().catch(console.error);\n }, [\n djClient,\n filters,\n before,\n after,\n sortConfig.key,\n sortConfig.direction,\n tableNamespace,\n namespace,\n debouncedSearch,\n gitConfigLoaded,\n isGitRoot,\n gitConfig,\n ]);\n\n const loadNext = () => {\n if (nextCursor) {\n setAfter(nextCursor);\n setBefore(null);\n }\n };\n const loadPrev = () => {\n if (prevCursor) {\n setAfter(null);\n setBefore(prevCursor);\n }\n };\n\n // Select options. TYPE options carry the recursive per-type node count for the\n // current namespace (e.g. \"Metric (342)\") — this replaces the old rail counts.\n const TYPE_LABELS = {\n metric: 'Metric',\n cube: 'Cube',\n dimension: 'Dimension',\n transform: 'Transform',\n source: 'Source',\n };\n const typeOptions = NODE_TYPE_ORDER.map(type => ({\n value: type,\n label: TYPE_LABELS[type],\n count: typeCounts?.[type] ?? null,\n }));\n\n // Renders a TYPE option as \"<name> <colored count pill>\" (pill colors match the\n // node-type badges). Right-aligns the pill in the menu; inline in the control.\n const formatTypeOption = (option, meta) => {\n const pill =\n option.count != null ? (\n <span\n style={{\n backgroundColor: NODE_TYPE_COLORS[option.value]?.bg ?? '#f1f5f9',\n color: NODE_TYPE_COLORS[option.value]?.color ?? '#475569',\n borderRadius: '8px',\n padding: '1px 8px',\n fontSize: '11px',\n fontWeight: 600,\n fontVariantNumeric: 'tabular-nums',\n }}\n >\n {option.count}\n </span>\n ) : null;\n return (\n <span\n style={{\n display: 'flex',\n alignItems: 'center',\n gap: '8px',\n justifyContent:\n meta?.context === 'menu' ? 'space-between' : 'flex-start',\n width: meta?.context === 'menu' ? '100%' : 'auto',\n }}\n >\n <span>{option.label}</span>\n {pill}\n </span>\n );\n };\n\n const modeOptions = [\n { value: 'published', label: 'Published' },\n { value: 'draft', label: 'Draft' },\n ];\n\n const statusOptions = [\n { value: 'VALID', label: 'Valid' },\n { value: 'INVALID', label: 'Needs fixes' },\n ];\n\n const userOptions = useMemo(\n () => users.map(u => ({ value: u.username, label: u.username })),\n [users],\n );\n const tagOptions = useMemo(\n () => tags.map(t => ({ value: t.name, label: t.display_name })),\n [tags],\n );\n\n const nodesList = retrieved ? (\n state.nodes.length > 0 ? (\n state.nodes.map(node => {\n const { leafName, namespacePath } = splitNodeName(node.name);\n const stateInfo = nodeState(node.current);\n const owners = node.owners || [];\n return (\n <tr\n key={node.name}\n style={{\n backgroundColor: stateInfo.needsAttention ? '#fffbeb' : undefined,\n }}\n >\n <td\n style={{\n ...CELL_STYLE,\n maxWidth: '300px',\n overflow: 'hidden',\n whiteSpace: 'nowrap',\n textOverflow: 'ellipsis',\n }}\n >\n <div>\n <a\n href={'/nodes/' + node.name}\n className=\"link-table\"\n title={node.name}\n style={{ fontWeight: 600, color: '#334155' }}\n >\n {leafName}\n </a>\n <span\n className=\"rounded-pill badge bg-secondary-soft\"\n style={{ marginLeft: '0.5rem' }}\n >\n {node.currentVersion}\n </span>\n </div>\n {namespacePath ? (\n <div\n title={namespacePath}\n style={{\n color: '#64748b',\n fontSize: '12px',\n marginTop: '2px',\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n }}\n >\n {namespacePath}\n </div>\n ) : null}\n </td>\n <td\n style={{\n ...MIDDLE_CELL_STYLE,\n maxWidth: '250px',\n overflow: 'hidden',\n whiteSpace: 'nowrap',\n textOverflow: 'ellipsis',\n color: '#64748b',\n }}\n >\n <a\n href={'/nodes/' + node.name}\n className=\"link-table\"\n style={{ color: '#475569', fontWeight: 400 }}\n >\n {node.type !== 'source' ? node.current.displayName : ''}\n </a>\n </td>\n <td style={MIDDLE_CELL_STYLE}>\n <Tooltip content={NODE_TYPE_DESCRIPTIONS[node.type]}>\n <span\n className={\n 'node_type__' + node.type.toLowerCase() + ' badge node_type'\n }\n >\n {node.type}\n </span>\n </Tooltip>\n </td>\n <td style={MIDDLE_CELL_STYLE}>\n <Tooltip content={stateInfo.validationTooltip}>\n <span\n style={{\n display: 'inline-flex',\n alignItems: 'center',\n borderRadius: '999px',\n border: `1px solid ${\n stateInfo.isValid ? '#28a745' : '#d39e00'\n }`,\n backgroundColor: stateInfo.isValid ? '#eaf7ee' : '#fff7df',\n color: stateInfo.isValid ? '#1f7a3a' : '#8a5b00',\n fontWeight: '600',\n fontSize: '12px',\n lineHeight: 1.2,\n padding: '6px 10px',\n whiteSpace: 'nowrap',\n }}\n >\n {stateInfo.validationLabel}\n </span>\n </Tooltip>\n </td>\n <td style={MIDDLE_CELL_STYLE}>\n <Tooltip content={stateInfo.publishTooltip}>\n <span\n style={{\n display: 'inline-flex',\n alignItems: 'center',\n borderRadius: '999px',\n border: `1px solid ${\n stateInfo.isPublished ? '#28a745' : '#d39e00'\n }`,\n backgroundColor: stateInfo.isPublished\n ? '#eaf7ee'\n : '#fff7df',\n color: stateInfo.isPublished ? '#1f7a3a' : '#8a5b00',\n fontWeight: '600',\n fontSize: '12px',\n lineHeight: 1.2,\n