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.
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
@@ -0,0 +1,362 @@
1
+ import {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react'; // useRef kept for hasFitRef
9
+ import * as React from 'react';
10
+ import ReactFlow, {
11
+ addEdge,
12
+ useEdgesState,
13
+ useNodesState,
14
+ Handle,
15
+ MarkerType,
16
+ Position,
17
+ } from 'reactflow';
18
+ import { useNavigate } from 'react-router-dom';
19
+ import dagre from 'dagre';
20
+ import 'reactflow/dist/style.css';
21
+ import DJClientContext from '../../providers/djclient';
22
+
23
+ const TYPE_COLORS = {
24
+ source: { border: '#00b368', text: '#00b368', bg: '#ccf7e5' },
25
+ transform: { border: '#0063b4', text: '#0063b4', bg: '#ccefff' },
26
+ metric: { border: '#a2283e', text: '#a2283e', bg: '#fad7dd' },
27
+ dimension: { border: '#a96621', text: '#a96621', bg: '#ffefd0' },
28
+ cube: { border: '#580076', text: '#580076', bg: '#dbafff' },
29
+ };
30
+
31
+ const NODE_WIDTH = 220;
32
+ const NODE_HEIGHT = 72;
33
+ const HANDLE_BASE = {
34
+ width: 1,
35
+ height: 1,
36
+ opacity: 0,
37
+ border: 'none',
38
+ background: 'transparent',
39
+ pointerEvents: 'none',
40
+ };
41
+ // ReactFlow connects Position.Right edges to handle.x + handle.width (right edge of handle).
42
+ // Setting right:0 puts the right edge flush with the node's right border → edge is flush.
43
+ // Similarly, left:0 puts the left edge flush with the node's left border.
44
+ const TARGET_HANDLE = { ...HANDLE_BASE, left: 0 };
45
+ const SOURCE_HANDLE = { ...HANDLE_BASE, right: 0 };
46
+
47
+ const BASE_EDGE = { strokeWidth: 1, stroke: '#dde3ec' };
48
+ const BASE_MARKER = {
49
+ type: MarkerType.ArrowClosed,
50
+ color: '#dde3ec',
51
+ width: 12,
52
+ height: 12,
53
+ };
54
+
55
+ function DimNode({ data }) {
56
+ const navigate = useNavigate();
57
+ const c = TYPE_COLORS[data.type] ?? {
58
+ border: '#cbd5e1',
59
+ text: '#64748b',
60
+ bg: '#f8fafc',
61
+ };
62
+ const label = (data.display_name || data.name || '').split('.').pop();
63
+ const namespace = (data.name || '').split('.').slice(0, -1).join('.');
64
+
65
+ const borderStyle = data.isCurrent
66
+ ? {
67
+ border: `3px solid ${c.border}`,
68
+ boxShadow: `0 0 0 1px ${c.border}33, 0 4px 12px rgba(0,0,0,0.12)`,
69
+ }
70
+ : {
71
+ border: `1px solid ${c.border}66`,
72
+ opacity: data.dimmed ? 0.3 : 1,
73
+ boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
74
+ };
75
+
76
+ return (
77
+ <>
78
+ <Handle type="target" position={Position.Left} style={TARGET_HANDLE} />
79
+ <div
80
+ onClick={() => navigate('/nodes/' + data.name)}
81
+ style={{
82
+ width: NODE_WIDTH,
83
+ padding: '12px 16px',
84
+ background: '#fff',
85
+ borderRadius: 12,
86
+ cursor: 'pointer',
87
+ transition: 'opacity 0.15s',
88
+ ...borderStyle,
89
+ }}
90
+ >
91
+ <div
92
+ style={{
93
+ display: 'flex',
94
+ alignItems: 'flex-start',
95
+ justifyContent: 'space-between',
96
+ gap: 8,
97
+ }}
98
+ >
99
+ <div
100
+ style={{
101
+ fontSize: 13,
102
+ fontWeight: 700,
103
+ color: '#0f172a',
104
+ lineHeight: 1.3,
105
+ flex: 1,
106
+ minWidth: 0,
107
+ overflow: 'hidden',
108
+ textOverflow: 'ellipsis',
109
+ whiteSpace: 'nowrap',
110
+ }}
111
+ >
112
+ {label}
113
+ </div>
114
+ <span
115
+ style={{
116
+ fontSize: 10,
117
+ fontWeight: 600,
118
+ color: c.text,
119
+ background: c.bg,
120
+ borderRadius: 5,
121
+ padding: '2px 6px',
122
+ textTransform: 'uppercase',
123
+ letterSpacing: '0.05em',
124
+ whiteSpace: 'nowrap',
125
+ flexShrink: 0,
126
+ }}
127
+ >
128
+ {data.type}
129
+ </span>
130
+ </div>
131
+ {namespace && (
132
+ <div
133
+ style={{
134
+ fontSize: 11,
135
+ color: '#94a3b8',
136
+ marginTop: 5,
137
+ overflow: 'hidden',
138
+ textOverflow: 'ellipsis',
139
+ whiteSpace: 'nowrap',
140
+ }}
141
+ >
142
+ {namespace}
143
+ </div>
144
+ )}
145
+ </div>
146
+ <Handle type="source" position={Position.Right} style={SOURCE_HANDLE} />
147
+ </>
148
+ );
149
+ }
150
+
151
+ const getLayoutedElements = (nodes, edges, currentName) => {
152
+ const g = new dagre.graphlib.Graph();
153
+ g.setDefaultEdgeLabel(() => ({}));
154
+ g.setGraph({
155
+ rankdir: 'LR',
156
+ nodesep: 24,
157
+ ranksep: 80,
158
+ ranker: 'longest-path',
159
+ });
160
+ nodes.forEach(n =>
161
+ g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT }),
162
+ );
163
+ edges.forEach(e => g.setEdge(e.source, e.target));
164
+ dagre.layout(g);
165
+ nodes.forEach(n => {
166
+ const pos = g.node(n.id);
167
+ n.position = { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 };
168
+ n.targetPosition = 'left';
169
+ n.sourcePosition = 'right';
170
+ });
171
+
172
+ // Translate so the current node's center sits at the origin (0, 0).
173
+ // fitView then centers on it regardless of how asymmetric the rest of the graph is.
174
+ const current = nodes.find(n => n.id === currentName);
175
+ if (current) {
176
+ const dx = -(current.position.x + NODE_WIDTH / 2);
177
+ const dy = -(current.position.y + NODE_HEIGHT / 2);
178
+ nodes.forEach(n => {
179
+ n.position = { x: n.position.x + dx, y: n.position.y + dy };
180
+ });
181
+ }
182
+
183
+ return { nodes, edges };
184
+ };
185
+
186
+ const buildRFNode = (n, currentName) => ({
187
+ id: n.name,
188
+ type: 'DimNode',
189
+ zIndex: 10,
190
+ data: {
191
+ name: n.name,
192
+ display_name: n.display_name,
193
+ type: n.type,
194
+ isCurrent: n.name === currentName,
195
+ dimmed: false,
196
+ },
197
+ });
198
+
199
+ export default function NodeDimensionsTab({ djNode }) {
200
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
201
+ const nodeTypes = useMemo(() => ({ DimNode }), []);
202
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
203
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
204
+ const [loaded, setLoaded] = useState(false);
205
+ const [rfInstance, setRfInstance] = useState(null);
206
+ const [currentNodeName, setCurrentNodeName] = useState(null);
207
+ const hasFitRef = useRef(false);
208
+
209
+ // Reset fit-view state whenever the node changes so a fresh fitView fires on load.
210
+ useEffect(() => {
211
+ hasFitRef.current = false;
212
+ setCurrentNodeName(null);
213
+ }, [djNode?.name]);
214
+
215
+ // Center viewport on the current node (which is placed at the origin by getLayoutedElements).
216
+ // rfInstance is state (not ref) so this re-runs if onInit fires after nodes are set.
217
+ useEffect(() => {
218
+ if (
219
+ nodes.length > 0 &&
220
+ rfInstance &&
221
+ currentNodeName &&
222
+ !hasFitRef.current
223
+ ) {
224
+ hasFitRef.current = true;
225
+ rfInstance.setCenter(0, 0, { zoom: 0.9 });
226
+ }
227
+ }, [nodes, rfInstance, currentNodeName]);
228
+
229
+ useEffect(() => {
230
+ if (!djNode?.name) return;
231
+
232
+ const makeEdge = (() => {
233
+ const seen = new Set();
234
+ return (source, target) => {
235
+ const id = `${source}->${target}`;
236
+ if (seen.has(id)) return null;
237
+ seen.add(id);
238
+ return { id, source, target, style: BASE_EDGE, markerEnd: BASE_MARKER };
239
+ };
240
+ })();
241
+
242
+ const applyLayout = (rfNodes, rfEdges) => {
243
+ const { nodes: ln, edges: le } = getLayoutedElements(
244
+ rfNodes,
245
+ rfEdges,
246
+ djNode.name,
247
+ );
248
+ setCurrentNodeName(djNode.name);
249
+ setNodes(ln);
250
+ setEdges(le);
251
+ setLoaded(true);
252
+ };
253
+
254
+ djClient
255
+ .dimensionDag(djNode.name)
256
+ .then(
257
+ ({
258
+ inbound = [],
259
+ inbound_edges = [],
260
+ outbound = [],
261
+ outbound_edges = [],
262
+ }) => {
263
+ if (inbound.length === 0 && outbound.length === 0) {
264
+ setLoaded(true);
265
+ return;
266
+ }
267
+ const allRelated = [...inbound, ...outbound];
268
+ const rfNodes = [
269
+ buildRFNode(djNode, djNode.name),
270
+ ...allRelated.map(n => buildRFNode(n, djNode.name)),
271
+ ];
272
+ const rfEdges = [
273
+ ...inbound_edges.map(e => makeEdge(e.source, e.target)),
274
+ ...outbound_edges.map(e => makeEdge(e.source, e.target)),
275
+ ].filter(Boolean);
276
+ applyLayout(rfNodes, rfEdges);
277
+ },
278
+ )
279
+ .catch(err => {
280
+ console.error(err);
281
+ setLoaded(true);
282
+ });
283
+ }, [djNode, djClient]);
284
+
285
+ const onNodeMouseEnter = useCallback(
286
+ (_, node) => {
287
+ const connectedIds = new Set([node.id]);
288
+ setEdges(eds => {
289
+ eds.forEach(e => {
290
+ if (e.source === node.id) connectedIds.add(e.target);
291
+ if (e.target === node.id) connectedIds.add(e.source);
292
+ });
293
+ return eds.map(e => {
294
+ const isConn = e.source === node.id || e.target === node.id;
295
+ const hoverColor = isConn ? '#475569' : '#cbd5e1';
296
+ return {
297
+ ...e,
298
+ style: { strokeWidth: isConn ? 2 : 1, stroke: hoverColor },
299
+ markerEnd: {
300
+ type: MarkerType.ArrowClosed,
301
+ color: hoverColor,
302
+ width: 12,
303
+ height: 12,
304
+ },
305
+ };
306
+ });
307
+ });
308
+ setNodes(ns =>
309
+ ns.map(n => ({
310
+ ...n,
311
+ data: { ...n.data, dimmed: !connectedIds.has(n.id) },
312
+ })),
313
+ );
314
+ },
315
+ [setEdges, setNodes],
316
+ );
317
+
318
+ const onNodeMouseLeave = useCallback(() => {
319
+ setEdges(eds =>
320
+ eds.map(e => ({ ...e, style: BASE_EDGE, markerEnd: BASE_MARKER })),
321
+ );
322
+ setNodes(ns => ns.map(n => ({ ...n, data: { ...n.data, dimmed: false } })));
323
+ }, [setEdges, setNodes]);
324
+
325
+ const onConnect = useCallback(
326
+ params => setEdges(eds => addEdge(params, eds)),
327
+ [setEdges],
328
+ );
329
+
330
+ if (loaded && nodes.length === 0) {
331
+ return (
332
+ <div style={{ padding: '2rem', color: '#64748b', fontSize: 14 }}>
333
+ No dimension links found for this node.
334
+ </div>
335
+ );
336
+ }
337
+
338
+ return (
339
+ <div
340
+ style={{
341
+ height: 'calc(100vh - 280px)',
342
+ minHeight: 400,
343
+ background: '#fff',
344
+ }}
345
+ >
346
+ {/* Ensure node HTML layer always renders above the edge SVG layer */}
347
+ <style>{`.react-flow__nodes { z-index: 10 !important; }`}</style>
348
+ <ReactFlow
349
+ nodes={nodes}
350
+ edges={edges}
351
+ nodeTypes={nodeTypes}
352
+ onNodesChange={onNodesChange}
353
+ onEdgesChange={onEdgesChange}
354
+ onConnect={onConnect}
355
+ onNodeMouseEnter={onNodeMouseEnter}
356
+ onNodeMouseLeave={onNodeMouseLeave}
357
+ onInit={setRfInstance}
358
+ proOptions={{ hideAttribution: true }}
359
+ />
360
+ </div>
361
+ );
362
+ }
@@ -26,6 +26,7 @@ const createDJNode = node => {
26
26
  const NodeColumnLineage = djNode => {
27
27
  const djClient = useContext(DJClientContext).DataJunctionAPI;
28
28
  const dagFetch = async (getLayoutedElements, setNodes, setEdges) => {
29
+ if (!djNode?.djNode) return;
29
30
  let relatedNodes = await djClient.node_lineage(djNode.djNode.name);
30
31
  let nodesMapping = {};
31
32
  let edgesMapping = {};
@@ -24,15 +24,15 @@ export default function NodesWithDimension({ node, djClient }) {
24
24
  {availableNodes.map(node => (
25
25
  <tr>
26
26
  <td>
27
- <a href={`/nodes/${node.name}`}>{node.display_name}</a>
27
+ <a href={`/nodes/${node.name}`}>{node.name}</a>
28
28
  </td>
29
- <td>
29
+ {/* <td>
30
30
  <span
31
31
  className={'node_type__' + node.type + ' badge node_type'}
32
32
  >
33
33
  {node.type}
34
34
  </span>
35
- </td>
35
+ </td> */}
36
36
  </tr>
37
37
  ))}
38
38
  </tbody>