datajunction-ui 0.0.93 → 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.
- package/package.json +1 -1
- package/src/app/components/NodeComponents.jsx +4 -0
- package/src/app/components/Tab.jsx +11 -16
- package/src/app/components/__tests__/Tab.test.jsx +4 -2
- package/src/app/hooks/useWorkspaceData.js +226 -0
- package/src/app/index.tsx +17 -1
- package/src/app/pages/MyWorkspacePage/ActiveBranchesSection.jsx +38 -107
- package/src/app/pages/MyWorkspacePage/MyNodesSection.jsx +31 -6
- package/src/app/pages/MyWorkspacePage/MyWorkspacePage.css +5 -0
- package/src/app/pages/MyWorkspacePage/NeedsAttentionSection.jsx +86 -100
- package/src/app/pages/MyWorkspacePage/TypeGroupGrid.jsx +7 -11
- package/src/app/pages/MyWorkspacePage/__tests__/ActiveBranchesSection.test.jsx +79 -11
- package/src/app/pages/MyWorkspacePage/__tests__/CollectionsSection.test.jsx +22 -0
- package/src/app/pages/MyWorkspacePage/__tests__/MaterializationsSection.test.jsx +57 -0
- package/src/app/pages/MyWorkspacePage/__tests__/MyNodesSection.test.jsx +60 -18
- package/src/app/pages/MyWorkspacePage/__tests__/MyWorkspacePage.test.jsx +156 -162
- package/src/app/pages/MyWorkspacePage/__tests__/NeedsAttentionSection.test.jsx +17 -18
- package/src/app/pages/MyWorkspacePage/__tests__/NotificationsSection.test.jsx +179 -0
- package/src/app/pages/MyWorkspacePage/__tests__/TypeGroupGrid.test.jsx +169 -49
- package/src/app/pages/MyWorkspacePage/index.jsx +41 -73
- package/src/app/pages/NodePage/NodeDataFlowTab.jsx +464 -0
- package/src/app/pages/NodePage/NodeDependenciesTab.jsx +1 -1
- package/src/app/pages/NodePage/NodeDimensionsTab.jsx +362 -0
- package/src/app/pages/NodePage/NodeLineageTab.jsx +1 -0
- package/src/app/pages/NodePage/NodesWithDimension.jsx +3 -3
- package/src/app/pages/NodePage/__tests__/NodeDataFlowTab.test.jsx +428 -0
- package/src/app/pages/NodePage/__tests__/NodeDependenciesTab.test.jsx +18 -1
- package/src/app/pages/NodePage/__tests__/NodeDimensionsTab.test.jsx +362 -0
- package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +28 -3
- package/src/app/pages/NodePage/__tests__/NodeWithDimension.test.jsx +2 -2
- package/src/app/pages/NodePage/index.jsx +15 -8
- package/src/app/services/DJService.js +73 -6
- package/src/app/services/__tests__/DJService.test.jsx +591 -0
- 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
|
-
|
|
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
|
-
|
|
26
|
-
const { data:
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
const
|
|
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
|
|
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
|
|
83
|
+
{/* Right Column (35%): action items first, then activity */}
|
|
111
84
|
<div className="workspace-right-column">
|
|
112
|
-
|
|
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
|
|
101
|
+
(dim.properties?.includes('primary_key') ? ' (PK)' : ''),
|
|
102
102
|
};
|
|
103
103
|
});
|
|
104
104
|
return (
|