datajunction-ui
Version:
DataJunction UI
1 lines • 106 kB
Source Map (JSON)
{"version":3,"file":"index-ByFlwzpD.cjs","sources":["../src/app/hooks/useWorkspaceData.js","../src/app/components/DashboardCard.jsx","../src/app/components/NodeComponents.jsx","../src/app/pages/MyWorkspacePage/NotificationsSection.jsx","../src/app/pages/MyWorkspacePage/NeedsAttentionSection.jsx","../src/app/pages/MyWorkspacePage/NodeList.jsx","../src/app/pages/MyWorkspacePage/TypeGroupGrid.jsx","../src/app/pages/MyWorkspacePage/MyNodesSection.jsx","../src/app/pages/MyWorkspacePage/MaterializationsSection.jsx","../src/app/pages/MyWorkspacePage/ActiveBranchesSection.jsx","../src/app/pages/MyWorkspacePage/index.jsx"],"sourcesContent":["import { useContext, useEffect, useState } from 'react';\nimport DJClientContext from '../providers/djclient';\n\n/**\n * Hook to fetch the current user\n */\nexport function useCurrentUser() {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [data, setData] = useState(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n const fetchUser = async () => {\n try {\n const user = await djClient.whoami();\n setData(user);\n } catch (err) {\n console.error('Error fetching user:', err);\n setError(err);\n }\n setLoading(false);\n };\n fetchUser();\n }, [djClient]);\n\n return { data, loading, error };\n}\n\n/**\n * Hook to fetch workspace owned nodes\n */\nexport function useWorkspaceOwnedNodes(username, limit = 5000) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [data, setData] = useState([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n if (!username) {\n setLoading(false);\n return;\n }\n\n const fetchData = async () => {\n try {\n const result = await djClient.getWorkspaceOwnedNodes(username, limit);\n setData(\n result?.data?.findNodesPaginated?.edges?.map(e => e.node) || [],\n );\n } catch (err) {\n console.error('Error fetching owned nodes:', err);\n setError(err);\n }\n setLoading(false);\n };\n fetchData();\n }, [djClient, username, limit]);\n\n return { data, loading, error };\n}\n\n/**\n * Hook to fetch workspace recently edited nodes\n */\nexport function useWorkspaceRecentlyEdited(username, limit = 5000) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [data, setData] = useState([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n if (!username) {\n setLoading(false);\n return;\n }\n\n const fetchData = async () => {\n try {\n const result = await djClient.getWorkspaceRecentlyEdited(\n username,\n limit,\n );\n setData(\n result?.data?.findNodesPaginated?.edges?.map(e => e.node) || [],\n );\n } catch (err) {\n console.error('Error fetching recently edited nodes:', err);\n setError(err);\n }\n setLoading(false);\n };\n fetchData();\n }, [djClient, username, limit]);\n\n return { data, loading, error };\n}\n\n/**\n * Hook to fetch watched nodes (notification subscriptions)\n */\nexport function useWorkspaceWatchedNodes(username) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [data, setData] = useState([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n if (!username) {\n setLoading(false);\n return;\n }\n\n const fetchData = async () => {\n try {\n const subscriptions = await djClient.getNotificationPreferences({\n entity_type: 'node',\n });\n const watchedNodeNames = (subscriptions || []).map(s => s.entity_name);\n\n if (watchedNodeNames.length > 0) {\n const nodes = await djClient.getNodesByNames(watchedNodeNames);\n setData(nodes || []);\n } else {\n setData([]);\n }\n } catch (err) {\n console.error('Error fetching watched nodes:', err);\n setError(err);\n }\n setLoading(false);\n };\n fetchData();\n }, [djClient, username]);\n\n return { data, loading, error };\n}\n\n/**\n * Hook to fetch workspace collections\n */\nexport function useWorkspaceCollections(username) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [data, setData] = useState([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n if (!username) {\n setLoading(false);\n return;\n }\n\n const fetchData = async () => {\n try {\n const result = await djClient.getWorkspaceCollections(username);\n setData(result?.data?.listCollections || []);\n } catch (err) {\n console.error('Error fetching collections:', err);\n setError(err);\n }\n setLoading(false);\n };\n fetchData();\n }, [djClient, username]);\n\n return { data, loading, error };\n}\n\n/**\n * Hook to fetch workspace notifications\n */\nexport function useWorkspaceNotifications(username, limit = 50) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [data, setData] = useState([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n if (!username) {\n setLoading(false);\n return;\n }\n\n const fetchData = async () => {\n try {\n const history = await djClient.getSubscribedHistory(limit);\n\n // Enrich with node info\n const nodeNames = Array.from(\n new Set((history || []).map(h => h.entity_name)),\n );\n let nodeInfoMap = {};\n if (nodeNames.length > 0) {\n const nodes = await djClient.getNodesByNames(nodeNames);\n nodeInfoMap = Object.fromEntries((nodes || []).map(n => [n.name, n]));\n }\n\n const enriched = (history || []).map(entry => ({\n ...entry,\n node_type: nodeInfoMap[entry.entity_name]?.type,\n display_name: nodeInfoMap[entry.entity_name]?.current?.displayName,\n }));\n\n setData(enriched);\n } catch (err) {\n console.error('Error fetching notifications:', err);\n setError(err);\n }\n setLoading(false);\n };\n fetchData();\n }, [djClient, username, limit]);\n\n return { data, loading, error };\n}\n\n/**\n * Hook to fetch workspace materializations\n */\nexport function useWorkspaceMaterializations(username, limit = 5000) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [data, setData] = useState([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n if (!username) {\n setLoading(false);\n return;\n }\n\n const fetchData = async () => {\n try {\n const result = await djClient.getWorkspaceMaterializations(\n username,\n limit,\n );\n setData(\n result?.data?.findNodesPaginated?.edges?.map(e => e.node) || [],\n );\n } catch (err) {\n console.error('Error fetching materializations:', err);\n setError(err);\n }\n setLoading(false);\n };\n fetchData();\n }, [djClient, username, limit]);\n\n return { data, loading, error };\n}\n\n/**\n * Hook to fetch all \"Needs Attention\" items\n * Returns an object with all actionable item categories\n */\nexport function useWorkspaceNeedsAttention(username) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [data, setData] = useState({\n nodesMissingDescription: [],\n invalidNodes: [],\n staleDrafts: [],\n orphanedDimensions: [],\n });\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n if (!username) {\n setLoading(false);\n return;\n }\n\n const fetchData = async () => {\n try {\n const [missingDescData, invalidData, draftData, orphanedData] =\n await Promise.all([\n djClient.getWorkspaceNodesMissingDescription(username, 5000),\n djClient.getWorkspaceInvalidNodes(username, 5000),\n djClient.getWorkspaceDraftNodes(username, 5000),\n djClient.getWorkspaceOrphanedDimensions(username, 5000),\n ]);\n\n const nodesMissingDescription =\n missingDescData?.data?.findNodesPaginated?.edges?.map(e => e.node) ||\n [];\n const invalidNodes =\n invalidData?.data?.findNodesPaginated?.edges?.map(e => e.node) || [];\n const orphanedDimensions =\n orphanedData?.data?.findNodesPaginated?.edges?.map(e => e.node) || [];\n\n // Filter drafts older than 7 days\n const sevenDaysAgo = new Date();\n sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);\n const allDrafts =\n draftData?.data?.findNodesPaginated?.edges?.map(e => e.node) || [];\n const staleDrafts = allDrafts.filter(node => {\n const updatedAt = new Date(node.current?.updatedAt);\n return updatedAt < sevenDaysAgo;\n });\n\n setData({\n nodesMissingDescription,\n invalidNodes,\n staleDrafts,\n orphanedDimensions,\n });\n } catch (err) {\n console.error('Error fetching needs attention items:', err);\n setError(err);\n }\n setLoading(false);\n };\n fetchData();\n }, [djClient, username]);\n\n return { data, loading, error };\n}\n\nconst toNodes = r => r?.data?.findNodesPaginated?.edges?.map(e => e.node) || [];\n\n/**\n * Fetches all workspace dashboard data in independent parallel groups so that\n * fast sections (My Nodes, Collections) render immediately while slower ones\n * (Needs Attention, namespace check) finish in the background.\n */\nexport function useWorkspaceDashboardData(username) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n\n const [data, setData] = useState({\n ownedNodes: [],\n ownedHasMore: {},\n recentlyEdited: [],\n editedHasMore: {},\n watchedNodes: [],\n collections: [],\n notifications: [],\n materializedNodes: [],\n needsAttention: {\n nodesMissingDescription: [],\n invalidNodes: [],\n staleDrafts: [],\n orphanedDimensions: [],\n },\n hasPersonalNamespace: null,\n });\n\n const [loadingStates, setLoadingStates] = useState({\n myNodes: true,\n collections: true,\n notifications: true,\n materializations: true,\n needsAttention: true,\n namespace: true,\n });\n\n useEffect(() => {\n if (!username) {\n setLoadingStates({\n myNodes: false,\n collections: false,\n notifications: false,\n materializations: false,\n needsAttention: false,\n namespace: false,\n });\n return;\n }\n\n // Reset all loading flags whenever the username changes (e.g. after initial login)\n setLoadingStates({\n myNodes: true,\n collections: true,\n notifications: true,\n materializations: true,\n needsAttention: true,\n namespace: true,\n });\n\n // Group 1: Owned nodes + recently edited + collections\n // Each type is queried independently with limit=11 so hasNextPage tells us if there are more.\n const NODE_TYPES = ['METRIC', 'TRANSFORM', 'DIMENSION', 'CUBE'];\n const hasNextPage = r =>\n r?.data?.findNodesPaginated?.pageInfo?.hasNextPage ?? false;\n\n const fetchMyNodesAndCollections = async () => {\n try {\n const [ownedByType, editedByType, collectionsResult] =\n await Promise.all([\n Promise.all(\n NODE_TYPES.map(t =>\n djClient.getWorkspaceOwnedNodes(username, 11, t),\n ),\n ),\n Promise.all(\n NODE_TYPES.map(t =>\n djClient.getWorkspaceRecentlyEdited(username, 11, t),\n ),\n ),\n djClient.getWorkspaceCollections(username),\n ]);\n\n const ownedHasMore = Object.fromEntries(\n NODE_TYPES.map((t, i) => [\n t.toLowerCase(),\n hasNextPage(ownedByType[i]),\n ]),\n );\n const editedHasMore = Object.fromEntries(\n NODE_TYPES.map((t, i) => [\n t.toLowerCase(),\n hasNextPage(editedByType[i]),\n ]),\n );\n\n setData(prev => ({\n ...prev,\n ownedNodes: ownedByType.flatMap(r => toNodes(r)),\n ownedHasMore,\n recentlyEdited: editedByType.flatMap(r => toNodes(r)),\n editedHasMore,\n collections: collectionsResult?.data?.listCollections || [],\n }));\n } catch (err) {\n console.error('Error fetching nodes/collections:', err);\n }\n setLoadingStates(prev => ({\n ...prev,\n myNodes: false,\n collections: false,\n }));\n };\n\n // Group 2: Watched nodes + notifications (2 round-trips each, run in parallel)\n const fetchWatchedAndNotifications = async () => {\n try {\n const [subscriptions, historyResult] = await Promise.all([\n djClient.getNotificationPreferences({ entity_type: 'node' }),\n djClient.getSubscribedHistory(50),\n ]);\n\n const watchedNodeNames = (subscriptions || []).map(s => s.entity_name);\n const historyEntries = historyResult || [];\n const notifNodeNames = Array.from(\n new Set(historyEntries.map(h => h.entity_name)),\n );\n\n const [watchedNodes, notifNodes] = await Promise.all([\n watchedNodeNames.length > 0\n ? djClient.getNodesByNames(watchedNodeNames)\n : Promise.resolve([]),\n notifNodeNames.length > 0\n ? djClient.getNodesByNames(notifNodeNames)\n : Promise.resolve([]),\n ]);\n\n const notifNodeMap = Object.fromEntries(\n (notifNodes || []).map(n => [n.name, n]),\n );\n const notifications = historyEntries.map(entry => ({\n ...entry,\n node_type: notifNodeMap[entry.entity_name]?.type,\n display_name: notifNodeMap[entry.entity_name]?.current?.displayName,\n }));\n\n setData(prev => ({\n ...prev,\n watchedNodes: watchedNodes || [],\n notifications,\n }));\n } catch (err) {\n console.error('Error fetching watched/notifications:', err);\n }\n setLoadingStates(prev => ({ ...prev, notifications: false }));\n };\n\n // Group 3: Materializations (small limit — we only display 5)\n const fetchMaterializations = async () => {\n try {\n const result = await djClient.getWorkspaceMaterializations(username, 6);\n setData(prev => ({ ...prev, materializedNodes: toNodes(result) }));\n } catch (err) {\n console.error('Error fetching materializations:', err);\n }\n setLoadingStates(prev => ({ ...prev, materializations: false }));\n };\n\n // Group 4: Needs Attention + personal namespace check (slower, loads last)\n const fetchNeedsAttentionAndNamespace = async () => {\n try {\n const usernameForNamespace = username.split('@')[0];\n const personalNamespace = `users.${usernameForNamespace}`;\n\n const [\n missingDescResult,\n invalidResult,\n draftResult,\n orphanedResult,\n namespacesList,\n ] = await Promise.all([\n djClient.getWorkspaceNodesMissingDescription(username, 100),\n djClient.getWorkspaceInvalidNodes(username, 100),\n djClient.getWorkspaceDraftNodes(username, 100),\n djClient.getWorkspaceOrphanedDimensions(username, 100),\n djClient.namespaces(),\n ]);\n\n const sevenDaysAgo = new Date();\n sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);\n const allDrafts = toNodes(draftResult);\n\n setData(prev => ({\n ...prev,\n needsAttention: {\n nodesMissingDescription: toNodes(missingDescResult),\n invalidNodes: toNodes(invalidResult),\n staleDrafts: allDrafts.filter(\n n => new Date(n.current?.updatedAt) < sevenDaysAgo,\n ),\n orphanedDimensions: toNodes(orphanedResult),\n },\n hasPersonalNamespace: (namespacesList || []).some(\n ns => ns.namespace === personalNamespace,\n ),\n }));\n } catch (err) {\n console.error('Error fetching needs attention:', err);\n }\n setLoadingStates(prev => ({\n ...prev,\n needsAttention: false,\n namespace: false,\n }));\n };\n\n // All four groups fire independently — each resolves its own loading flag\n fetchMyNodesAndCollections();\n fetchWatchedAndNotifications();\n fetchMaterializations();\n fetchNeedsAttentionAndNamespace();\n }, [djClient, username]);\n\n return { data, loadingStates };\n}\n\n/**\n * Hook to check if personal namespace exists\n */\nexport function usePersonalNamespace(username) {\n const djClient = useContext(DJClientContext).DataJunctionAPI;\n const [exists, setExists] = useState(null);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState(null);\n\n useEffect(() => {\n if (!username) {\n setLoading(false);\n return;\n }\n\n const checkNamespace = async () => {\n try {\n // Extract username without domain for namespace\n const usernameForNamespace = username.split('@')[0];\n const personalNamespace = `users.${usernameForNamespace}`;\n const namespaces = await djClient.namespaces();\n const namespaceExists = namespaces.some(\n ns => ns.namespace === personalNamespace,\n );\n setExists(namespaceExists);\n } catch (err) {\n console.error('Error checking namespace:', err);\n setError(err);\n setExists(false);\n }\n setLoading(false);\n };\n checkNamespace();\n }, [djClient, username]);\n\n return { exists, loading, error };\n}\n","import * as React from 'react';\nimport { Link } from 'react-router-dom';\nimport LoadingIcon from '../icons/LoadingIcon';\n\n/**\n * Reusable card component for dashboard sections\n *\n * @param {Object} props\n * @param {string} props.title - Section title\n * @param {string} [props.actionLink] - Optional link URL for \"View All\" or other action\n * @param {string} [props.actionText] - Text for action link (defaults to \"View All →\")\n * @param {boolean} [props.loading] - Show loading spinner\n * @param {React.ReactNode} [props.emptyState] - Content to show when empty (if loading is false and no children)\n * @param {React.ReactNode} props.children - Card content\n * @param {Object} [props.cardStyle] - Additional styles for the card wrapper\n * @param {Object} [props.contentStyle] - Additional styles for the content area\n * @param {boolean} [props.showHeader] - Whether to show the header (defaults to true)\n */\nexport function DashboardCard({\n title,\n actionLink,\n actionText = 'View All →',\n loading = false,\n emptyState = null,\n children,\n cardStyle = {},\n contentStyle = {},\n showHeader = true,\n}) {\n // Check if children has actual content (not false, null, undefined)\n const hasContent =\n React.Children.toArray(children).filter(\n child => child !== false && child !== null && child !== undefined,\n ).length > 0;\n const showEmptyState = !loading && !hasContent && emptyState;\n\n return (\n <section>\n {showHeader && (\n <div className=\"section-title-row\">\n <h2 className=\"settings-section-title\">{title}</h2>\n {actionLink && (\n <Link to={actionLink} style={{ fontSize: '13px' }}>\n {actionText}\n </Link>\n )}\n </div>\n )}\n <div\n className=\"settings-card\"\n style={{\n padding: '0.75rem',\n ...cardStyle,\n ...contentStyle,\n }}\n >\n {loading ? (\n <div style={{ textAlign: 'center', padding: '2rem' }}>\n <LoadingIcon />\n </div>\n ) : showEmptyState ? (\n emptyState\n ) : (\n children\n )}\n </div>\n </section>\n );\n}\n\n/**\n * Empty state component for dashboard cards\n */\nexport function DashboardCardEmpty({ icon, message, action }) {\n return (\n <div\n style={{\n padding: '2rem',\n textAlign: 'center',\n color: '#666',\n fontSize: '12px',\n }}\n >\n {icon && (\n <div style={{ fontSize: '24px', marginBottom: '0.5rem' }}>{icon}</div>\n )}\n <p>{message}</p>\n {action}\n </div>\n );\n}\n\nexport default DashboardCard;\n","import * as React from 'react';\n\n/**\n * Reusable node type badge component\n *\n * @param {Object} props\n * @param {string} props.type - Node type (e.g., \"METRIC\", \"DIMENSION\")\n * @param {('small'|'medium'|'large')} [props.size='medium'] - Badge size\n * @param {boolean} [props.abbreviated=false] - Show only first character\n * @param {Object} [props.style] - Additional styles\n */\nexport function NodeBadge({\n type,\n size = 'medium',\n abbreviated = false,\n style = {},\n}) {\n if (!type) return null;\n\n const sizeMap = {\n small: { fontSize: '7px', padding: '0.44em' },\n medium: { fontSize: '9px', padding: '0.44em' },\n large: { fontSize: '11px', padding: '0.44em' },\n };\n\n const sizeStyles = sizeMap[size] || sizeMap.medium;\n const displayText = abbreviated ? type.charAt(0) : type;\n\n return (\n <span\n className={`node_type__${type.toLowerCase()} badge node_type`}\n style={{\n ...sizeStyles,\n flexShrink: 0,\n ...style,\n marginRight: 0,\n }}\n >\n {displayText}\n </span>\n );\n}\n\n/**\n * Reusable node link component\n *\n * @param {Object} props\n * @param {Object} props.node - Node object with name and current.displayName\n * @param {('small'|'medium'|'large')} [props.size='medium'] - Link size\n * @param {boolean} [props.showFullName=false] - Show full node name instead of display name\n * @param {boolean} [props.ellipsis=false] - Enable text overflow ellipsis\n * @param {Object} [props.style] - Additional styles\n */\nexport function NodeLink({\n node,\n size = 'medium',\n showFullName = false,\n ellipsis = false,\n style = {},\n}) {\n if (!node?.name) return null;\n\n const sizeMap = {\n small: { fontSize: '10px', fontWeight: '500' },\n medium: { fontSize: '12px', fontWeight: '500' },\n large: { fontSize: '14px', fontWeight: '500' },\n };\n\n const sizeStyles = sizeMap[size] || sizeMap.medium;\n const displayName = showFullName\n ? node.name\n : node.current?.displayName || node.name.split('.').pop();\n\n const ellipsisStyles = ellipsis\n ? {\n display: 'block',\n flex: 1,\n minWidth: 0,\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n }\n : {};\n\n return (\n <a\n href={`/nodes/${node.name}`}\n style={{\n ...sizeStyles,\n textDecoration: 'none',\n ...ellipsisStyles,\n ...style,\n }}\n >\n {displayName}\n </a>\n );\n}\n\n/**\n * Combined node display with link and badge\n *\n * @param {Object} props\n * @param {Object} props.node - Node object\n * @param {('small'|'medium'|'large')} [props.size='medium'] - Overall size\n * @param {boolean} [props.showBadge=true] - Show type badge\n * @param {boolean} [props.abbreviatedBadge=false] - Show abbreviated badge\n * @param {boolean} [props.ellipsis=false] - Enable text overflow ellipsis\n * @param {string} [props.gap='6px'] - Gap between link and badge\n * @param {Object} [props.containerStyle] - Additional container styles\n */\nexport function NodeDisplay({\n node,\n size = 'medium',\n showBadge = true,\n abbreviatedBadge = false,\n ellipsis = false,\n gap = '6px',\n containerStyle = {},\n}) {\n if (!node) return null;\n\n return (\n <div\n style={{\n display: 'flex',\n alignItems: 'center',\n gap,\n ...(ellipsis ? { minWidth: 0, overflow: 'hidden' } : {}),\n ...containerStyle,\n }}\n >\n <NodeLink node={node} size={size} ellipsis={ellipsis} />\n {showBadge && node.type && (\n <NodeBadge\n type={node.type}\n size={size}\n abbreviated={abbreviatedBadge}\n />\n )}\n </div>\n );\n}\n\n/**\n * Node chip component - compact display with border\n * Used in NeedsAttentionSection\n *\n * @param {Object} props\n * @param {Object} props.node - Node object\n * @param {boolean} [props.abbreviatedBadge=true] - Show abbreviated badge\n */\nexport function NodeChip({ node }) {\n if (!node) return null;\n\n return (\n <a\n href={`/nodes/${node.name}`}\n style={{\n display: 'inline-flex',\n alignItems: 'center',\n gap: '3px',\n padding: '2px 6px',\n fontSize: '12px',\n border: '1px solid var(--border-color, #ddd)',\n borderRadius: '3px',\n textDecoration: 'none',\n color: 'inherit',\n backgroundColor: 'var(--card-bg, #f8f9fa)',\n whiteSpace: 'nowrap',\n flexShrink: 0,\n }}\n >\n <NodeBadge type={node.type} size=\"small\" abbreviated={true} />\n {node.current?.displayName || node.name.split('.').pop()}\n </a>\n );\n}\n","import * as React from 'react';\nimport { Link } from 'react-router-dom';\nimport { formatRelativeTime } from '../../utils/date';\nimport DashboardCard, {\n DashboardCardEmpty,\n} from '../../components/DashboardCard';\nimport { NodeBadge } from '../../components/NodeComponents';\n\n// Notifications Section - matches NotificationBell dropdown styling\nexport function NotificationsSection({ notifications, username, loading }) {\n // Group notifications by node\n const groupedByNode = {};\n notifications.forEach(entry => {\n if (!groupedByNode[entry.entity_name]) {\n groupedByNode[entry.entity_name] = [];\n }\n groupedByNode[entry.entity_name].push(entry);\n });\n\n // Convert to array and sort by most recent activity\n const grouped = Object.entries(groupedByNode)\n .map(([nodeName, entries]) => ({\n nodeName,\n entries,\n mostRecent: entries[0], // Assuming already sorted by date\n count: entries.length,\n }))\n .slice(0, 15);\n\n const notificationsList = grouped.map(\n ({ nodeName, entries, mostRecent, count }) => {\n const version = mostRecent.details?.version;\n const href = version\n ? `/nodes/${mostRecent.entity_name}/revisions/${version}`\n : `/nodes/${mostRecent.entity_name}/history`;\n\n // Get unique users who made updates\n const allUsers = entries\n .map(e => e.user)\n .filter(u => u != null && u !== '');\n const users = [...new Set(allUsers)];\n\n // If no users found, fall back to mostRecent.user\n const userText =\n users.length === 0\n ? mostRecent.user === username\n ? 'you'\n : mostRecent.user?.split('@')[0] || 'unknown'\n : users.length === 1\n ? users[0] === username\n ? 'you'\n : users[0]?.split('@')[0]\n : users.includes(username)\n ? `you + ${users.length - 1} other${users.length - 1 > 1 ? 's' : ''}`\n : `${users.length} users`;\n\n return (\n <div\n key={nodeName}\n style={{\n display: 'flex',\n alignItems: 'center',\n gap: '0.5rem',\n padding: '0.5rem 0',\n borderBottom: '1px solid var(--border-color, #eee)',\n fontSize: '12px',\n }}\n >\n <a\n href={href}\n style={{\n fontSize: '13px',\n fontWeight: '500',\n textDecoration: 'none',\n flexShrink: 0,\n }}\n >\n {mostRecent.display_name || mostRecent.entity_name.split('.').pop()}\n </a>\n {mostRecent.node_type && (\n <NodeBadge\n type={mostRecent.node_type.toUpperCase()}\n size=\"medium\"\n />\n )}\n <span style={{ color: '#888', whiteSpace: 'nowrap' }}>\n {count > 1\n ? `${count} updates`\n : mostRecent.activity_type?.charAt(0).toUpperCase() +\n mostRecent.activity_type?.slice(1) +\n 'd'}{' '}\n by {userText}\n {' · '}\n {formatRelativeTime(mostRecent.created_at)}\n </span>\n </div>\n );\n },\n );\n\n return (\n <DashboardCard\n title=\"Notifications\"\n actionLink=\"/notifications\"\n loading={loading}\n cardStyle={{ padding: '0', maxHeight: '300px', overflowY: 'auto' }}\n emptyState={\n <DashboardCardEmpty\n icon=\"🔔\"\n message=\"No notifications yet.\"\n action={\n <p style={{ fontSize: '11px', marginTop: '0.5rem' }}>\n Watch nodes to get notified of changes.\n </p>\n }\n />\n }\n >\n {notifications.length > 0 && (\n <div\n style={{\n display: 'flex',\n flexDirection: 'column',\n gap: '0.5rem',\n padding: '0.5rem',\n }}\n >\n {notificationsList}\n </div>\n )}\n </DashboardCard>\n );\n}\n","import * as React from 'react';\nimport { Link } from 'react-router-dom';\nimport LoadingIcon from '../../icons/LoadingIcon';\nimport { NodeChip } from '../../components/NodeComponents';\n\n// Needs Attention Section with single-line categories\nexport function NeedsAttentionSection({\n nodesMissingDescription,\n invalidNodes,\n staleDrafts,\n staleMaterializations,\n orphanedDimensions,\n username,\n loading,\n personalNamespace,\n hasPersonalNamespace,\n namespaceLoading,\n}) {\n const categories = [\n {\n id: 'invalid',\n icon: '❌',\n label: 'Invalid',\n nodes: invalidNodes,\n viewAllLink: `/?ownedBy=${username}&statuses=INVALID`,\n },\n {\n id: 'stale-drafts',\n icon: '⏰',\n label: 'Stale Drafts',\n nodes: staleDrafts,\n viewAllLink: `/?ownedBy=${username}&mode=DRAFT`,\n },\n {\n id: 'stale-materializations',\n icon: '📦',\n label: 'Stale Materializations',\n nodes: staleMaterializations,\n viewAllLink: `/?ownedBy=${username}&hasMaterialization=true`,\n },\n {\n id: 'no-description',\n icon: '📝',\n label: 'No Description',\n nodes: nodesMissingDescription,\n viewAllLink: `/?ownedBy=${username}&missingDescription=true`,\n },\n {\n id: 'orphaned-dimensions',\n icon: '🔗',\n label: 'Orphaned Dimensions',\n nodes: orphanedDimensions,\n viewAllLink: `/?ownedBy=${username}&orphanedDimension=true`,\n },\n ];\n\n return (\n <section style={{ minWidth: 0, width: '100%' }}>\n <h2 className=\"settings-section-title\">Needs Attention</h2>\n <div className=\"settings-card\" style={{ padding: '0.25rem 0.75rem' }}>\n {loading ? (\n <div style={{ textAlign: 'center', padding: '1rem' }}>\n <LoadingIcon />\n </div>\n ) : (\n <>\n {categories.map((cat, i) => (\n <div\n key={cat.id}\n style={{\n display: 'flex',\n alignItems: 'center',\n gap: '0.5rem',\n padding: '0.35rem 0',\n borderBottom:\n i < categories.length - 1\n ? '1px solid var(--border-color, #f0f0f0)'\n : 'none',\n minWidth: 0,\n }}\n >\n {/* Label + count */}\n <span\n style={{\n fontSize: '11px',\n fontWeight: 600,\n color: '#555',\n whiteSpace: 'nowrap',\n flexShrink: 0,\n }}\n >\n {cat.label}{' '}\n <span\n style={{\n color: cat.nodes.length > 0 ? '#dc3545' : '#28a745',\n fontWeight: 500,\n }}\n >\n ({cat.nodes.length})\n </span>\n </span>\n\n {/* Chips with right-side fade */}\n {cat.nodes.length > 0 ? (\n <div\n style={{\n flex: 1,\n overflow: 'hidden',\n maskImage:\n 'linear-gradient(to right, black 75%, transparent 100%)',\n WebkitMaskImage:\n 'linear-gradient(to right, black 75%, transparent 100%)',\n }}\n >\n <div\n style={{\n display: 'flex',\n gap: '0.25rem',\n flexWrap: 'nowrap',\n }}\n >\n {cat.nodes.slice(0, 20).map(node => (\n <NodeChip key={node.name} node={node} />\n ))}\n </div>\n </div>\n ) : (\n <span style={{ fontSize: '10px', color: '#28a745', flex: 1 }}>\n All good\n </span>\n )}\n\n {/* Arrow link — always visible, outside the fade */}\n {cat.nodes.length > 0 && (\n <Link\n to={cat.viewAllLink}\n style={{ fontSize: '11px', flexShrink: 0 }}\n >\n →\n </Link>\n )}\n </div>\n ))}\n\n {!namespaceLoading && !hasPersonalNamespace && (\n <div\n style={{\n display: 'flex',\n alignItems: 'center',\n gap: '0.5rem',\n padding: '0.35rem 0',\n fontSize: '11px',\n }}\n >\n <span style={{ fontWeight: 600, color: '#555', flexShrink: 0 }}>\n Personal namespace\n </span>\n <Link\n to={`/namespaces/${personalNamespace}`}\n style={{ fontSize: '10px' }}\n >\n Create {personalNamespace} →\n </Link>\n </div>\n )}\n </>\n )}\n </div>\n </section>\n );\n}\n","import * as React from 'react';\nimport NodeListActions from '../../components/NodeListActions';\nimport { NodeDisplay } from '../../components/NodeComponents';\n\n// Node List Component\nexport function NodeList({ nodes, showUpdatedAt }) {\n const formatDateTime = dateStr => {\n const date = new Date(dateStr);\n return date.toLocaleDateString('en-US', {\n month: 'short',\n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit',\n });\n };\n\n return (\n <div className=\"node-list\">\n {nodes.map(node => (\n <div key={node.name} className=\"subscription-item node-list-item\">\n <div className=\"node-list-item-content\">\n <div className=\"node-list-item-display\">\n <NodeDisplay\n node={node}\n size=\"large\"\n ellipsis={true}\n gap=\"0.5rem\"\n />\n </div>\n <div className=\"node-list-item-name\">{node.name}</div>\n </div>\n <div className=\"node-list-item-actions\">\n {showUpdatedAt && node.current?.updatedAt && (\n <span className=\"node-list-item-updated\">\n {formatDateTime(node.current.updatedAt)}\n </span>\n )}\n <div className=\"node-list-item-actions-wrapper\">\n <NodeListActions nodeName={node.name} />\n </div>\n </div>\n </div>\n ))}\n </div>\n );\n}\n","import * as React from 'react';\nimport { Link } from 'react-router-dom';\nimport { NodeBadge, NodeLink } from '../../components/NodeComponents';\nimport NodeListActions from '../../components/NodeListActions';\n\n// Format timestamp to relative time\nfunction formatRelativeTime(dateStr) {\n const date = new Date(dateStr);\n const now = new Date();\n const diffMs = now - date;\n const diffMins = Math.floor(diffMs / (1000 * 60));\n const diffHours = Math.floor(diffMs / (1000 * 60 * 60));\n const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));\n\n if (diffMins < 60) return `${diffMins}m`;\n if (diffHours < 24) return `${diffHours}h`;\n if (diffDays < 30) return `${diffDays}d`;\n return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });\n}\n\n// Capitalize first letter\nfunction capitalize(str) {\n return str.charAt(0).toUpperCase() + str.slice(1);\n}\n\n// Type Card Component\nfunction TypeCard({ type, nodes, hasMore, username, activeTab }) {\n const displayNodes = nodes;\n\n // Build filter URL based on active tab and type\n const getFilterUrl = () => {\n const params = new URLSearchParams();\n\n if (activeTab === 'owned') {\n params.append('ownedBy', username);\n params.append('type', type);\n } else if (activeTab === 'watched') {\n // For watched, we'd need a different filter - for now use type only\n params.append('type', type);\n } else if (activeTab === 'edited') {\n params.append('updatedBy', username);\n params.append('type', type);\n }\n\n return `/?${params.toString()}`;\n };\n\n return (\n <div className=\"type-group-card\">\n <div className=\"type-group-header\">\n <span className=\"type-group-title\">{capitalize(type)}s</span>\n </div>\n\n <div className=\"type-group-nodes\">\n {displayNodes.map(node => {\n const isInvalid = node.status === 'invalid';\n const isDraft = node.mode === 'draft';\n const gitRepo = node.gitInfo?.repo;\n const gitBranch = node.gitInfo?.branch;\n const defaultBranch = node.gitInfo?.defaultBranch;\n const isDefaultBranch = gitBranch && gitBranch === defaultBranch;\n const hasGitInfo = gitRepo || gitBranch;\n\n return (\n <div key={node.name} className=\"type-group-node\">\n <NodeBadge type={node.type} size=\"small\" abbreviated={true} />\n {isInvalid && (\n <span\n className=\"type-group-node-status-icon\"\n title=\"Invalid node\"\n >\n ⚠️\n </span>\n )}\n {isDraft && (\n <span\n className=\"type-group-node-status-icon\"\n title=\"Draft mode\"\n >\n 📝\n </span>\n )}\n <NodeLink\n node={node}\n size=\"small\"\n ellipsis={true}\n style={{ flex: 1, minWidth: 0 }}\n />\n {hasGitInfo && (\n <span\n className={`type-group-node-git-info ${\n isDefaultBranch ? 'type-group-node-git-info--default' : ''\n }`}\n title={`${gitRepo || ''}${gitRepo && gitBranch ? ' / ' : ''}${\n gitBranch || ''\n }${isDefaultBranch ? ' (default)' : ''}`}\n style={{\n display: 'flex',\n alignItems: 'center',\n gap: '4px',\n maxWidth: '40%',\n }}\n >\n {isDefaultBranch && (\n <span style={{ lineHeight: 1, flexShrink: 0 }}>⭐</span>\n )}\n {gitRepo && (\n <span\n style={{\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n minWidth: 0,\n }}\n >\n {gitRepo}\n </span>\n )}\n {gitRepo && gitBranch && (\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 style={{ flexShrink: 0 }}\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 {gitBranch && (\n <span\n style={{\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n whiteSpace: 'nowrap',\n minWidth: 0,\n }}\n >\n {gitBranch}\n </span>\n )}\n </span>\n )}\n {node.current?.updatedAt && (\n <span className=\"type-group-node-time\">\n {formatRelativeTime(node.current.updatedAt)}\n </span>\n )}\n <div className=\"type-group-node-actions\">\n <NodeListActions nodeName={node.name} />\n </div>\n </div>\n );\n })}\n </div>\n\n {hasMore && (\n <Link to={getFilterUrl()} className=\"type-group-more\">\n More →\n </Link>\n )}\n </div>\n );\n}\n\n// Type Group Grid Component (2-column layout)\nexport function TypeGroupGrid({ groupedData, username, activeTab }) {\n if (!groupedData || groupedData.length === 0) {\n return (\n <div\n style={{\n padding: '1rem',\n textAlign: 'center',\n color: '#666',\n fontSize: '12px',\n }}\n >\n No nodes to display\n </div>\n );\n }\n\n return (\n <div className=\"type-group-grid\">\n {groupedData.map(group => (\n <TypeCard\n key={group.type}\n type={group.type}\n nodes={group.nodes}\n hasMore={group.hasMore}\n username={username}\n activeTab={activeTab}\n />\n ))}\n </div>\n );\n}\n","import * as React from 'react';\nimport { Link } from 'react-router-dom';\nimport DashboardCard from '../../components/DashboardCard';\nimport { NodeList } from './NodeList';\nimport { TypeGroupGrid } from './TypeGroupGrid';\n\n// Node type display order\nconst NODE_TYPE_ORDER = ['metric', 'cube', 'dimension', 'transform', 'source'];\n\n// Helper to group nodes by type\nfunction groupNodesByType(nodes, hasMoreMap = {}) {\n const groups = {};\n nodes.forEach(node => {\n const type = (node.type || 'unknown').toLowerCase();\n if (!groups[type]) groups[type] = [];\n groups[type].push(node);\n });\n\n // Return types in defined order, only including types with nodes\n return NODE_TYPE_ORDER.filter(type => groups[type]?.length > 0).map(type => ({\n type,\n nodes: groups[type],\n hasMore: hasMoreMap[type] ?? false,\n }));\n}\n\n// My Nodes Section (owned + watched, with tabs)\nexport function MyNodesSection({\n ownedNodes,\n ownedHasMore = {},\n watchedNodes,\n recentlyEdited,\n editedHasMore = {},\n username,\n loading,\n}) {\n const [activeTab, setActiveTab] = React.useState('owned');\n const hasAutoSwitchedTab = React.useRef(false);\n\n // Once data loads, auto-switch to \"edited\" if user has no owned nodes but has edits\n React.useEffect(() => {\n if (hasAutoSwitchedTab.current) return;\n if (ownedNodes.length === 0 && recentlyEdited.length > 0) {\n setActiveTab('edited');\n hasAutoSwitchedTab.current = true;\n } else if (ownedNodes.length > 0) {\n hasAutoSwitchedTab.current = true; // owned nodes exist, stick with default\n }\n }, [ownedNodes.length, recentlyEdited.length]);\n const [groupByType, setGroupByType] = React.useState(() => {\n const stored = localStorage.getItem('workspace_groupByType');\n return stored === null ? true : stored === 'true';\n });\n\n const ownedNames = new Set(ownedNodes.map(n => n.name));\n const watchedOnly = watchedNodes.filter(n => !ownedNames.has(n.name));\n\n const allMyNodeNames = new Set([\n ...ownedNames,\n ...watchedNodes.map(n => n.name),\n ]);\n const editedOnly = recentlyEdited.filter(n => !allMyNodeNames.has(n.name));\n\n const getDisplayNodes = () => {\n switch (activeTab) {\n case 'owned':\n return ownedNodes;\n case 'watched':\n return watchedOnly;\n case 'edited':\n return recentlyEdited;\n default:\n return ownedNodes;\n }\n };\n const displayNodes = getDisplayNodes();\n\n const hasAnyContent =\n ownedNodes.length > 0 ||\n watchedOnly.length > 0 ||\n recentlyEdited.length > 0;\n const maxDisplay = 8;\n\n // Handle group by type toggle\n const handleGroupByTypeChange = e => {\n const checked = e.target.checked;\n setGroupByType(checked);\n localStorage.setItem('workspace_groupByType', checked.toString());\n };\n\n // Pick the right hasMore map for the active tab\n const activeHasMore =\n activeTab === 'owned'\n ? ownedHasMore\n : activeTab === 'edited'\n ? editedHasMore\n : {};\n\n // Group nodes by type if enabled\n const groupedData = groupByType\n ? groupNodesByType(displayNodes, activeHasMore)\n : null;\n\n return (\n <DashboardCard\n title=\"My Nodes\"\n actionLink={`/?ownedBy=${username}`}\n loading={loading}\n cardStyle={{\n padding: '0.25rem 0.75rem',\n }}\n emptyState={\n <div style={{ padding: '0.75rem 0' }}>\n <p\n style={{ fontSize: '12px', color: '#666', marginBottom: '0.75rem' }}\n >\n No nodes yet.\n </p>\n <div style={{ display: 'flex', gap: '0.75rem' }}>\n <div\n style={{\n flex: 1,\n padding: '0.75rem',\n backgroundColor: 'var(--card-bg, #f8f9fa)',\n border: '1px dashed var(--border-color, #dee2e6)',\n borderRadius: '6px',\n textAlign: 'center',\n }}\n >\n <div style={{ fontSize: '16px', marginBottom: '0.25rem' }}>\n ➕\n </div>\n <div\n style={{\n fontSize: '11px',\n fontWeight: '500',\n marginBottom: '0.25rem',\n }}\n >\n Create a node\n </div>\n <p\n style={{\n fontSize: '10px',\n color: '#666',\n marginBottom: '0.5rem',\n }}\n >\n Build your data model\n </p>\n <Link\n to=\"/create/source\"\n style={{\n display: 'inline-block',\n padding: '3px 8px',\n fontSize: '10px',\n backgroundColor: 'var(--primary-color, #4a90d9)',\n color: '#fff',\n borderRadius: '4px',\n textDecoration: 'none',\n }}\n >\n Create →\n </Link>\n </div>\n <div\n style={{\n flex: 1,\n padding: '0.75rem',\n backgroundColor: 'var(--card-bg, #f8f9fa)',\n border: '1px dashed var(--border-color, #dee2e6)',\n borderRadius: '6px',\n textAlign: 'center',\n }}\n >\n <div style={{ fontSize: '16px', marginBottom: '0.25rem' }}>\n 👤\n </div>\n <div\n style={{\n fontSize: '11px',\n fontWeight: '500',\n marginBottom: '0.25rem',\n }}\n >\n Claim ownership\n </div>\n <p\n style={{\n fontSize: '10px',\n color: '#666',\n marginBottom: '0.5rem',\n }}\n >\n Add yourself as owner\n </p>\n <Link\n to=\"/\"\n style={{\n display: 'inline-b