datajunction-ui 0.0.92 → 0.0.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/app/components/NodeComponents.jsx +4 -0
  3. package/src/app/components/Tab.jsx +11 -16
  4. package/src/app/components/__tests__/Tab.test.jsx +4 -2
  5. package/src/app/hooks/useWorkspaceData.js +226 -0
  6. package/src/app/index.tsx +17 -1
  7. package/src/app/pages/MyWorkspacePage/ActiveBranchesSection.jsx +38 -107
  8. package/src/app/pages/MyWorkspacePage/MyNodesSection.jsx +31 -6
  9. package/src/app/pages/MyWorkspacePage/MyWorkspacePage.css +5 -0
  10. package/src/app/pages/MyWorkspacePage/NeedsAttentionSection.jsx +86 -100
  11. package/src/app/pages/MyWorkspacePage/TypeGroupGrid.jsx +7 -11
  12. package/src/app/pages/MyWorkspacePage/__tests__/ActiveBranchesSection.test.jsx +79 -11
  13. package/src/app/pages/MyWorkspacePage/__tests__/CollectionsSection.test.jsx +22 -0
  14. package/src/app/pages/MyWorkspacePage/__tests__/MaterializationsSection.test.jsx +57 -0
  15. package/src/app/pages/MyWorkspacePage/__tests__/MyNodesSection.test.jsx +60 -18
  16. package/src/app/pages/MyWorkspacePage/__tests__/MyWorkspacePage.test.jsx +156 -162
  17. package/src/app/pages/MyWorkspacePage/__tests__/NeedsAttentionSection.test.jsx +17 -18
  18. package/src/app/pages/MyWorkspacePage/__tests__/NotificationsSection.test.jsx +179 -0
  19. package/src/app/pages/MyWorkspacePage/__tests__/TypeGroupGrid.test.jsx +169 -49
  20. package/src/app/pages/MyWorkspacePage/index.jsx +41 -73
  21. package/src/app/pages/NodePage/NodeDataFlowTab.jsx +464 -0
  22. package/src/app/pages/NodePage/NodeDependenciesTab.jsx +1 -1
  23. package/src/app/pages/NodePage/NodeDimensionsTab.jsx +362 -0
  24. package/src/app/pages/NodePage/NodeLineageTab.jsx +1 -0
  25. package/src/app/pages/NodePage/NodesWithDimension.jsx +3 -3
  26. package/src/app/pages/NodePage/__tests__/NodeDataFlowTab.test.jsx +428 -0
  27. package/src/app/pages/NodePage/__tests__/NodeDependenciesTab.test.jsx +18 -1
  28. package/src/app/pages/NodePage/__tests__/NodeDimensionsTab.test.jsx +362 -0
  29. package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +28 -3
  30. package/src/app/pages/NodePage/__tests__/NodeWithDimension.test.jsx +2 -2
  31. package/src/app/pages/NodePage/index.jsx +15 -8
  32. package/src/app/services/DJService.js +73 -6
  33. package/src/app/services/__tests__/DJService.test.jsx +591 -0
  34. package/src/styles/index.css +32 -0
@@ -1,20 +1,10 @@
1
1
  import * as React from 'react';
2
2
  import LoadingIcon from '../../icons/LoadingIcon';
3
- import {
4
- useCurrentUser,
5
- useWorkspaceOwnedNodes,
6
- useWorkspaceRecentlyEdited,
7
- useWorkspaceWatchedNodes,
8
- useWorkspaceCollections,
9
- useWorkspaceNotifications,
10
- useWorkspaceMaterializations,
11
- useWorkspaceNeedsAttention,
12
- usePersonalNamespace,
13
- } from '../../hooks/useWorkspaceData';
3
+ import { useCurrentUser } from '../../providers/UserProvider';
4
+ import { useWorkspaceDashboardData } from '../../hooks/useWorkspaceData';
14
5
  import { NotificationsSection } from './NotificationsSection';
15
6
  import { NeedsAttentionSection } from './NeedsAttentionSection';
16
7
  import { MyNodesSection } from './MyNodesSection';
17
- import { CollectionsSection } from './CollectionsSection';
18
8
  import { MaterializationsSection } from './MaterializationsSection';
19
9
  import { ActiveBranchesSection } from './ActiveBranchesSection';
20
10
 
@@ -22,36 +12,33 @@ import 'styles/settings.css';
22
12
  import './MyWorkspacePage.css';
23
13
 
24
14
  export function MyWorkspacePage() {
25
- // Use custom hooks for all data fetching
26
- const { data: currentUser, loading: userLoading } = useCurrentUser();
27
- const { data: ownedNodes, loading: ownedLoading } = useWorkspaceOwnedNodes(
15
+ const { currentUser, loading: userLoading } = useCurrentUser();
16
+ const { data: workspaceData, loadingStates } = useWorkspaceDashboardData(
28
17
  currentUser?.username,
29
18
  );
30
- const { data: recentlyEdited, loading: editedLoading } =
31
- useWorkspaceRecentlyEdited(currentUser?.username);
32
- const { data: watchedNodes, loading: watchedLoading } =
33
- useWorkspaceWatchedNodes(currentUser?.username);
34
- const { data: collections, loading: collectionsLoading } =
35
- useWorkspaceCollections(currentUser?.username);
36
- const { data: notifications, loading: notificationsLoading } =
37
- useWorkspaceNotifications(currentUser?.username);
38
- const { data: materializedNodes, loading: materializationsLoading } =
39
- useWorkspaceMaterializations(currentUser?.username);
40
- const { data: needsAttentionData, loading: needsAttentionLoading } =
41
- useWorkspaceNeedsAttention(currentUser?.username);
42
- const { exists: hasPersonalNamespace, loading: namespaceLoading } =
43
- usePersonalNamespace(currentUser?.username);
44
19
 
45
- // Extract needs attention data
46
20
  const {
47
- nodesMissingDescription = [],
48
- invalidNodes = [],
49
- staleDrafts = [],
50
- orphanedDimensions = [],
51
- } = needsAttentionData || {};
21
+ ownedNodes = [],
22
+ ownedHasMore = {},
23
+ recentlyEdited = [],
24
+ editedHasMore = {},
25
+ watchedNodes = [],
26
+ notifications = [],
27
+ materializedNodes = [],
28
+ needsAttention: {
29
+ nodesMissingDescription = [],
30
+ invalidNodes = [],
31
+ staleDrafts = [],
32
+ orphanedDimensions = [],
33
+ } = {},
34
+ hasPersonalNamespace = null,
35
+ } = workspaceData;
52
36
 
53
- // Combine loading states for "My Nodes" section
54
- const myNodesLoading = ownedLoading || editedLoading || watchedLoading;
37
+ const myNodesLoading = loadingStates.myNodes;
38
+ const notificationsLoading = loadingStates.notifications;
39
+ const materializationsLoading = loadingStates.materializations;
40
+ const needsAttentionLoading = loadingStates.needsAttention;
41
+ const namespaceLoading = loadingStates.namespace;
55
42
 
56
43
  // Filter stale materializations (> 72 hours old)
57
44
  const staleMaterializations = materializedNodes.filter(node => {
@@ -61,13 +48,6 @@ export function MyWorkspacePage() {
61
48
  return hoursSinceUpdate > 72;
62
49
  });
63
50
 
64
- const hasActionableItems =
65
- nodesMissingDescription.length > 0 ||
66
- invalidNodes.length > 0 ||
67
- staleDrafts.length > 0 ||
68
- staleMaterializations.length > 0 ||
69
- orphanedDimensions.length > 0;
70
-
71
51
  // Personal namespace for the user
72
52
  const usernameForNamespace = currentUser?.username?.split('@')[0] || '';
73
53
  const personalNamespace = `users.${usernameForNamespace}`;
@@ -86,63 +66,51 @@ export function MyWorkspacePage() {
86
66
  // Calculate stats
87
67
  return (
88
68
  <div className="settings-page" style={{ padding: '1.5rem 2rem' }}>
89
- {/* Two Column Layout: Collections/Organization (left) + Activity (right) */}
90
69
  <div className="workspace-layout">
91
- {/* Left Column: Organization (65%) */}
70
+ {/* Left Column (65%): My Nodes */}
92
71
  <div className="workspace-left-column">
93
- {/* Collections (My + Featured) */}
94
- <CollectionsSection
95
- collections={collections}
96
- loading={collectionsLoading}
97
- currentUser={currentUser}
98
- />
99
-
100
- {/* My Nodes */}
101
72
  <MyNodesSection
102
73
  ownedNodes={ownedNodes}
74
+ ownedHasMore={ownedHasMore}
103
75
  watchedNodes={watchedNodes}
104
76
  recentlyEdited={recentlyEdited}
77
+ editedHasMore={editedHasMore}
105
78
  username={currentUser?.username}
106
79
  loading={myNodesLoading}
107
80
  />
108
81
  </div>
109
82
 
110
- {/* Right Column: Activity (35%) */}
83
+ {/* Right Column (35%): action items first, then activity */}
111
84
  <div className="workspace-right-column">
112
- {/* Notifications */}
85
+ <NeedsAttentionSection
86
+ nodesMissingDescription={nodesMissingDescription}
87
+ invalidNodes={invalidNodes}
88
+ staleDrafts={staleDrafts}
89
+ staleMaterializations={staleMaterializations}
90
+ orphanedDimensions={orphanedDimensions}
91
+ username={currentUser?.username}
92
+ loading={needsAttentionLoading || materializationsLoading}
93
+ personalNamespace={personalNamespace}
94
+ hasPersonalNamespace={hasPersonalNamespace}
95
+ namespaceLoading={namespaceLoading}
96
+ />
97
+
113
98
  <NotificationsSection
114
99
  notifications={notifications}
115
100
  username={currentUser?.username}
116
101
  loading={notificationsLoading}
117
102
  />
118
103
 
119
- {/* Active Branches */}
120
104
  <ActiveBranchesSection
121
105
  ownedNodes={ownedNodes}
122
106
  recentlyEdited={recentlyEdited}
123
107
  loading={myNodesLoading}
124
108
  />
125
109
 
126
- {/* Materializations */}
127
110
  <MaterializationsSection
128
111
  nodes={materializedNodes}
129
112
  loading={materializationsLoading}
130
113
  />
131
-
132
- {/* Needs Attention */}
133
- <NeedsAttentionSection
134
- nodesMissingDescription={nodesMissingDescription}
135
- invalidNodes={invalidNodes}
136
- staleDrafts={staleDrafts}
137
- staleMaterializations={staleMaterializations}
138
- orphanedDimensions={orphanedDimensions}
139
- username={currentUser?.username}
140
- hasItems={hasActionableItems}
141
- loading={needsAttentionLoading || materializationsLoading}
142
- personalNamespace={personalNamespace}
143
- hasPersonalNamespace={hasPersonalNamespace}
144
- namespaceLoading={namespaceLoading}
145
- />
146
114
  </div>
147
115
  </div>
148
116
  </div>
@@ -0,0 +1,464 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react';
2
+ import * as React from 'react';
3
+ import { Sankey, Tooltip } from 'recharts';
4
+ import { useNavigate } from 'react-router-dom';
5
+ import DJClientContext from '../../providers/djclient';
6
+ import LoadingIcon from '../../icons/LoadingIcon';
7
+
8
+ // Match badge background colors from index.css .node_type__* classes
9
+ const TYPE_COLORS = {
10
+ source: '#ccf7e5',
11
+ transform: '#ccefff',
12
+ metric: '#fad7dd',
13
+ dimension: '#ffefd0',
14
+ cube: '#dbafff',
15
+ };
16
+
17
+ const TYPE_BORDER_COLORS = {
18
+ source: '#00b368',
19
+ transform: '#0063b4',
20
+ metric: '#a2283e',
21
+ dimension: '#a96621',
22
+ cube: '#580076',
23
+ };
24
+
25
+ const TYPE_LAYER_ORDER = ['source', 'transform', 'dimension', 'metric', 'cube'];
26
+
27
+ // Returns a slightly darker version of the pastel fill for the node border
28
+ const DARKER_FILL = {
29
+ source: '#8de8c3',
30
+ transform: '#8ed6f7',
31
+ metric: '#f0a3b0',
32
+ dimension: '#ffd08a',
33
+ cube: '#bc80f5',
34
+ };
35
+
36
+ function SankeyNode({
37
+ x,
38
+ y,
39
+ width,
40
+ height,
41
+ payload,
42
+ currentNodeName,
43
+ rightmostType,
44
+ onNavigate,
45
+ hoveredNodeName,
46
+ onNodeHover,
47
+ }) {
48
+ if (!payload) return null;
49
+ if (payload.type === 'phantom') return <g />;
50
+ const isHovered = hoveredNodeName === payload.name;
51
+ const isDimmed = hoveredNodeName && !isHovered;
52
+ const baseFill = TYPE_COLORS[payload.type] ?? '#f1f5f9';
53
+ const hoverFill = DARKER_FILL[payload.type] ?? '#cbd5e1';
54
+ const borderColor = DARKER_FILL[payload.type] ?? '#cbd5e1';
55
+ const isCurrent = payload.name === currentNodeName;
56
+ const label = (payload.display_name || payload.name || '').split('.').pop();
57
+ const isRightmost = payload.type === rightmostType;
58
+ const labelX = isRightmost ? x + width + 8 : x - 8;
59
+ const labelAnchor = isRightmost ? 'start' : 'end';
60
+
61
+ return (
62
+ <g
63
+ style={{
64
+ cursor: payload.name ? 'pointer' : 'default',
65
+ opacity: isDimmed ? 0.55 : 1,
66
+ transition: 'opacity 0.15s',
67
+ }}
68
+ onMouseEnter={() => onNodeHover && onNodeHover(payload.name)}
69
+ onMouseLeave={() => onNodeHover && onNodeHover(null)}
70
+ onClick={() =>
71
+ payload.name && onNavigate && onNavigate('/nodes/' + payload.name)
72
+ }
73
+ >
74
+ <rect
75
+ x={x}
76
+ y={y}
77
+ width={width}
78
+ height={height}
79
+ fill={isHovered ? hoverFill : baseFill}
80
+ fillOpacity={1}
81
+ stroke={isCurrent || isHovered ? borderColor : 'none'}
82
+ strokeWidth={1.5}
83
+ rx={2}
84
+ />
85
+ <text
86
+ x={labelX}
87
+ y={y + height / 2}
88
+ textAnchor={labelAnchor}
89
+ dominantBaseline="middle"
90
+ fontSize={11}
91
+ fill="#374151"
92
+ style={{ userSelect: 'none', pointerEvents: 'none' }}
93
+ >
94
+ {label}
95
+ </text>
96
+ </g>
97
+ );
98
+ }
99
+
100
+ function SankeyLink({
101
+ sourceX,
102
+ targetX,
103
+ sourceY,
104
+ targetY,
105
+ sourceControlX,
106
+ targetControlX,
107
+ linkWidth,
108
+ index,
109
+ payload,
110
+ hoveredNodeName,
111
+ }) {
112
+ const [linkHovered, setLinkHovered] = useState(false);
113
+ const hw = Math.max(linkWidth, 1);
114
+ const d = `
115
+ M${sourceX},${sourceY - hw / 2}
116
+ C${sourceControlX},${sourceY - hw / 2} ${targetControlX},${
117
+ targetY - hw / 2
118
+ } ${targetX},${targetY - hw / 2}
119
+ L${targetX},${targetY + hw / 2}
120
+ C${targetControlX},${targetY + hw / 2} ${sourceControlX},${
121
+ sourceY + hw / 2
122
+ } ${sourceX},${sourceY + hw / 2}
123
+ Z
124
+ `;
125
+ const targetType = payload?.target?.type;
126
+ if (targetType === 'phantom') return <g />;
127
+ const sourceType = payload?.source?.type;
128
+ const fromColor = TYPE_COLORS[sourceType] ?? '#e2e8f0';
129
+ const toColor = TYPE_COLORS[targetType] ?? '#e2e8f0';
130
+ const gradientId = `link-grad-${index}`;
131
+
132
+ const isConnected =
133
+ hoveredNodeName &&
134
+ (payload?.source?.name === hoveredNodeName ||
135
+ payload?.target?.name === hoveredNodeName);
136
+ const opacity = linkHovered
137
+ ? 0.85
138
+ : hoveredNodeName
139
+ ? isConnected
140
+ ? 0.8
141
+ : 0.15
142
+ : 0.38;
143
+
144
+ return (
145
+ <g>
146
+ <defs>
147
+ <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
148
+ <stop offset="0%" stopColor={fromColor} stopOpacity={opacity} />
149
+ <stop offset="100%" stopColor={toColor} stopOpacity={opacity} />
150
+ </linearGradient>
151
+ </defs>
152
+ <path
153
+ d={d}
154
+ fill={`url(#${gradientId})`}
155
+ stroke="none"
156
+ style={{ cursor: 'pointer', transition: 'opacity 0.15s' }}
157
+ onMouseEnter={() => setLinkHovered(true)}
158
+ onMouseLeave={() => setLinkHovered(false)}
159
+ />
160
+ </g>
161
+ );
162
+ }
163
+
164
+ export default function NodeDataFlowTab({ djNode }) {
165
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
166
+ const navigate = useNavigate();
167
+ const [sankeyData, setSankeyData] = useState(null);
168
+ const [loading, setLoading] = useState(true);
169
+ const [containerWidth, setContainerWidth] = useState(0);
170
+ const [hoveredNodeName, setHoveredNodeName] = useState(null);
171
+ const containerRef = useRef(null);
172
+
173
+ useEffect(() => {
174
+ const el = containerRef.current;
175
+ if (!el) return;
176
+ // Read initial width synchronously so the chart fills the container on first paint
177
+ setContainerWidth(el.getBoundingClientRect().width);
178
+ const observer = new ResizeObserver(entries => {
179
+ setContainerWidth(entries[0].contentRect.width);
180
+ });
181
+ observer.observe(el);
182
+ return () => observer.disconnect();
183
+ }, []);
184
+
185
+ useEffect(() => {
186
+ if (!djNode?.name) return;
187
+ setLoading(true);
188
+ Promise.all([
189
+ djClient.upstreamsGQL(djNode.name),
190
+ djClient.downstreamsGQL(djNode.name),
191
+ ])
192
+ .then(async ([upstreamNodes, downstreamNodes]) => {
193
+ const normalize = n => ({ ...n, type: n.type?.toLowerCase() });
194
+ const upstream = (upstreamNodes || []).map(normalize);
195
+ const downstream = (downstreamNodes || []).map(normalize);
196
+
197
+ // Fetch downstream cubes in one batch call
198
+ const cubeNames = downstream
199
+ .filter(n => n.type === 'cube')
200
+ .map(n => n.name);
201
+ const cubeNodes = await djClient.findCubesWithMetrics(cubeNames);
202
+
203
+ const nonCubeDownstreams = downstream.filter(n => n.type !== 'cube');
204
+ const allNodes = [
205
+ djNode,
206
+ ...upstream,
207
+ ...cubeNodes,
208
+ ...nonCubeDownstreams,
209
+ ];
210
+
211
+ // Deduplicate nodes
212
+ const seen = new Set();
213
+ const nodes = [];
214
+ allNodes.forEach(n => {
215
+ if (n && !seen.has(n.name)) {
216
+ seen.add(n.name);
217
+ nodes.push(n);
218
+ }
219
+ });
220
+
221
+ // Sort so seed node is first within its type group — it will appear at the top
222
+ // of its column when sort={false} is used.
223
+ const seedName = djNode?.name;
224
+ nodes.sort((a, b) => {
225
+ const aLayer = TYPE_LAYER_ORDER.indexOf(a.type);
226
+ const bLayer = TYPE_LAYER_ORDER.indexOf(b.type);
227
+ if (aLayer !== bLayer) return aLayer - bLayer;
228
+ if (a.name === seedName) return -1;
229
+ if (b.name === seedName) return 1;
230
+ return 0;
231
+ });
232
+
233
+ const nodeIndex = {};
234
+ nodes.forEach((n, i) => {
235
+ nodeIndex[n.name] = i;
236
+ });
237
+
238
+ const links = [];
239
+ nodes.forEach(node => {
240
+ (node.current?.parents || node.parents || []).forEach(parent => {
241
+ if (
242
+ parent.name &&
243
+ nodeIndex[parent.name] !== undefined &&
244
+ nodeIndex[node.name] !== undefined
245
+ ) {
246
+ links.push({
247
+ source: nodeIndex[parent.name],
248
+ target: nodeIndex[node.name],
249
+ value: 1,
250
+ });
251
+ }
252
+ });
253
+ });
254
+
255
+ // recharts forces any node with no outgoing links to maxDepth (the cube column).
256
+ // Fix: give free-floating metrics a tiny phantom outgoing link so they stay in
257
+ // the metric column. The phantom node renders as invisible.
258
+ const hasOutgoing = new Set(links.map(l => l.source));
259
+ const phantomLinks = [];
260
+ nodes.forEach((node, i) => {
261
+ if (node.type === 'metric' && !hasOutgoing.has(i)) {
262
+ phantomLinks.push({ source: i, target: nodes.length, value: 0.01 });
263
+ }
264
+ });
265
+ if (phantomLinks.length > 0) {
266
+ nodes.push({
267
+ name: '__phantom__',
268
+ type: 'phantom',
269
+ display_name: '',
270
+ });
271
+ links.push(...phantomLinks);
272
+ }
273
+
274
+ setSankeyData({ nodes, links });
275
+ setLoading(false);
276
+ })
277
+ .catch(err => {
278
+ console.error(err);
279
+ setLoading(false);
280
+ });
281
+ }, [djNode, djClient]);
282
+
283
+ // Always render the sentinel div so containerRef is mounted before data loads
284
+ if (loading || !sankeyData || sankeyData.links.length === 0) {
285
+ return (
286
+ <div>
287
+ {/* Sentinel must be in DOM so ResizeObserver fires even during loading */}
288
+ <div ref={containerRef} style={{ width: '100%', height: 0 }} />
289
+ {loading ? (
290
+ <div style={{ padding: '2rem' }}>
291
+ <LoadingIcon />
292
+ </div>
293
+ ) : (
294
+ <div style={{ padding: '2rem', color: '#64748b', fontSize: 14 }}>
295
+ No data flow relationships found for this node.
296
+ </div>
297
+ )}
298
+ </div>
299
+ );
300
+ }
301
+
302
+ const counts = sankeyData.nodes.reduce((acc, n) => {
303
+ if (n.type !== 'phantom') acc[n.type] = (acc[n.type] || 0) + 1;
304
+ return acc;
305
+ }, {});
306
+
307
+ const summaryParts = TYPE_LAYER_ORDER.filter(t => counts[t]).map(
308
+ t => `${counts[t]} ${t}${counts[t] > 1 ? 's' : ''}`,
309
+ );
310
+
311
+ // Height driven by the tallest column, not total node count
312
+ const colDepths = {};
313
+ sankeyData.nodes.forEach(n => {
314
+ if (n.type !== 'phantom') {
315
+ const col = TYPE_LAYER_ORDER.indexOf(n.type);
316
+ colDepths[col] = (colDepths[col] || 0) + 1;
317
+ }
318
+ });
319
+ const maxColNodes = Math.max(...Object.values(colDepths), 1);
320
+ // Derive chart height so each node is at least MIN_NODE_HEIGHT px tall.
321
+ // recharts fills: chartHeight - top - bottom = maxNodes * nodeHeight + (maxNodes - 1) * nodePadding
322
+ // Solving for chartHeight given a desired minimum nodeHeight:
323
+ const MIN_NODE_HEIGHT = 24;
324
+ const NODE_PADDING = 12;
325
+ const MARGIN_V = 20; // top + bottom margin
326
+ const chartHeight = Math.max(
327
+ 280,
328
+ maxColNodes * (MIN_NODE_HEIGHT + NODE_PADDING) - NODE_PADDING + MARGIN_V,
329
+ );
330
+
331
+ // Rightmost column determines label side (right); everything else labels left
332
+ const rightmostType =
333
+ TYPE_LAYER_ORDER.slice()
334
+ .reverse()
335
+ .find(t => counts[t]) ?? 'metric';
336
+
337
+ // Measure label widths per side to set margins
338
+ const canvas = document.createElement('canvas');
339
+ const ctx = canvas.getContext('2d');
340
+ ctx.font = '11px system-ui, sans-serif';
341
+ const measureLabel = n =>
342
+ ctx.measureText((n.display_name || n.name || '').split('.').pop()).width;
343
+ const rightNodes = sankeyData.nodes.filter(n => n.type === rightmostType);
344
+ const leftNodes = sankeyData.nodes.filter(
345
+ n => n.type !== rightmostType && n.type !== 'phantom',
346
+ );
347
+ const rightMargin =
348
+ Math.ceil(Math.max(0, ...rightNodes.map(measureLabel))) + 16;
349
+ const leftMargin =
350
+ Math.ceil(Math.max(0, ...leftNodes.map(measureLabel))) + 16;
351
+
352
+ const nodeEl = (
353
+ <SankeyNode
354
+ currentNodeName={djNode?.name}
355
+ rightmostType={rightmostType}
356
+ onNavigate={navigate}
357
+ hoveredNodeName={hoveredNodeName}
358
+ onNodeHover={setHoveredNodeName}
359
+ />
360
+ );
361
+ const linkEl = <SankeyLink hoveredNodeName={hoveredNodeName} />;
362
+
363
+ return (
364
+ <div style={{ padding: '1.5rem 0.75rem' }}>
365
+ <div
366
+ style={{
367
+ fontSize: 13,
368
+ fontWeight: 700,
369
+ color: '#374151',
370
+ textTransform: 'uppercase',
371
+ letterSpacing: '0.06em',
372
+ marginBottom: '0.75rem',
373
+ }}
374
+ >
375
+ {summaryParts.join(' → ')}
376
+ </div>
377
+ <div
378
+ style={{
379
+ display: 'flex',
380
+ gap: '1rem',
381
+ marginBottom: '1.25rem',
382
+ flexWrap: 'wrap',
383
+ }}
384
+ >
385
+ {TYPE_LAYER_ORDER.filter(t => counts[t]).map(type => (
386
+ <span
387
+ key={type}
388
+ style={{
389
+ display: 'flex',
390
+ alignItems: 'center',
391
+ gap: 5,
392
+ fontSize: 12,
393
+ color: '#64748b',
394
+ }}
395
+ >
396
+ <span
397
+ style={{
398
+ width: 12,
399
+ height: 12,
400
+ background: TYPE_COLORS[type],
401
+ border: `1.5px solid ${TYPE_BORDER_COLORS[type]}`,
402
+ borderRadius: 2,
403
+ display: 'inline-block',
404
+ }}
405
+ />
406
+ {type}
407
+ </span>
408
+ ))}
409
+ </div>
410
+ {/* Sentinel div: measures true available width without the Sankey influencing it */}
411
+ <div ref={containerRef} style={{ width: '100%', height: 0 }} />
412
+ {containerWidth > 0 && (
413
+ <Sankey
414
+ width={containerWidth}
415
+ height={chartHeight}
416
+ data={sankeyData}
417
+ nodePadding={NODE_PADDING}
418
+ nodeWidth={16}
419
+ margin={{ top: 10, right: rightMargin, bottom: 10, left: leftMargin }}
420
+ node={nodeEl}
421
+ link={linkEl}
422
+ sort={false}
423
+ >
424
+ <Tooltip
425
+ content={({ active, payload }) => {
426
+ if (!active || !payload?.length) return null;
427
+ const item = payload[0]?.payload;
428
+ if (!item) return null;
429
+ const name =
430
+ item.name ||
431
+ `${item.source?.display_name || item.source?.name} → ${
432
+ item.target?.display_name || item.target?.name
433
+ }`;
434
+ return (
435
+ <div
436
+ style={{
437
+ background: 'white',
438
+ border: '1px solid #e2e8f0',
439
+ padding: '8px 12px',
440
+ borderRadius: 4,
441
+ fontSize: 12,
442
+ boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
443
+ }}
444
+ >
445
+ <div style={{ fontWeight: 600 }}>{name}</div>
446
+ {item.type && (
447
+ <div
448
+ style={{
449
+ color: TYPE_COLORS[item.type] || '#64748b',
450
+ marginTop: 2,
451
+ }}
452
+ >
453
+ {item.type}
454
+ </div>
455
+ )}
456
+ </div>
457
+ );
458
+ }}
459
+ />
460
+ </Sankey>
461
+ )}
462
+ </div>
463
+ );
464
+ }
@@ -98,7 +98,7 @@ export function NodeDimensionsList({ rawDimensions }) {
98
98
  value: dim.name,
99
99
  label:
100
100
  labelize(dim.name.split('.').slice(-1)[0]) +
101
- (dim.properties.includes('primary_key') ? ' (PK)' : ''),
101
+ (dim.properties?.includes('primary_key') ? ' (PK)' : ''),
102
102
  };
103
103
  });
104
104
  return (