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
@@ -0,0 +1,428 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor, fireEvent } from '@testing-library/react';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import DJClientContext from '../../../providers/djclient';
5
+ import NodeDataFlowTab from '../NodeDataFlowTab';
6
+
7
+ // Sankey mock renders the node/link elements with fake props so SankeyNode/SankeyLink are covered
8
+ jest.mock('recharts', () => {
9
+ const React = require('react');
10
+ return {
11
+ Sankey: ({ node: nodeEl, link: linkEl, data, children }) => {
12
+ const nodes = data?.nodes || [];
13
+ const makeNode = (payload, extra = {}) =>
14
+ React.isValidElement(nodeEl)
15
+ ? React.cloneElement(nodeEl, {
16
+ x: 10,
17
+ y: 10,
18
+ width: 20,
19
+ height: 30,
20
+ payload,
21
+ hoveredNodeName: null,
22
+ onNodeHover: () => {},
23
+ onNavigate: () => {},
24
+ ...extra,
25
+ })
26
+ : null;
27
+ const makeLink = (payload, extra = {}) =>
28
+ React.isValidElement(linkEl)
29
+ ? React.cloneElement(linkEl, {
30
+ sourceX: 0,
31
+ targetX: 100,
32
+ sourceY: 50,
33
+ targetY: 50,
34
+ sourceControlX: 30,
35
+ targetControlX: 70,
36
+ linkWidth: 5,
37
+ index: 0,
38
+ payload,
39
+ ...extra,
40
+ })
41
+ : null;
42
+ const firstNode = nodes[0];
43
+ const secondNode = nodes[1] || nodes[0];
44
+ return (
45
+ <div data-testid="sankey">
46
+ {/* SankeyNode: null payload → returns null */}
47
+ {makeNode(null)}
48
+ {/* SankeyNode: phantom type → returns <g/> */}
49
+ {firstNode && makeNode({ ...firstNode, type: 'phantom' })}
50
+ {/* SankeyNode: normal */}
51
+ {firstNode && makeNode(firstNode)}
52
+ {/* SankeyNode: hovered (isHovered=true) */}
53
+ {firstNode &&
54
+ makeNode(firstNode, { hoveredNodeName: firstNode.name })}
55
+ {/* SankeyNode: dimmed (hoveredNodeName !== this node) */}
56
+ {firstNode && makeNode(firstNode, { hoveredNodeName: 'other.node' })}
57
+ {/* SankeyNode: no name → cursor:default */}
58
+ {makeNode({ type: 'metric', display_name: 'No Name Node' })}
59
+ {/* SankeyLink: phantom target → returns <g/> */}
60
+ {firstNode &&
61
+ secondNode &&
62
+ makeLink({
63
+ source: firstNode,
64
+ target: { ...secondNode, type: 'phantom' },
65
+ })}
66
+ {/* SankeyLink: no hover */}
67
+ {firstNode &&
68
+ secondNode &&
69
+ makeLink({ source: firstNode, target: secondNode })}
70
+ {/* SankeyLink: hovered matching source → isConnected=true, opacity=0.8 */}
71
+ {firstNode &&
72
+ secondNode &&
73
+ makeLink(
74
+ { source: firstNode, target: secondNode },
75
+ { hoveredNodeName: firstNode.name },
76
+ )}
77
+ {/* SankeyLink: hovered not matching → isConnected=false, opacity=0.15 */}
78
+ {firstNode &&
79
+ secondNode &&
80
+ makeLink(
81
+ { source: firstNode, target: secondNode },
82
+ { hoveredNodeName: 'something.else' },
83
+ )}
84
+ {children}
85
+ </div>
86
+ );
87
+ },
88
+ // Tooltip mock invokes the content function with various args to cover lines 413-421
89
+ Tooltip: ({ content }) => {
90
+ if (typeof content !== 'function') return null;
91
+ return (
92
+ <div data-testid="tooltip">
93
+ {/* active=false → line 413 returns null */}
94
+ {content({
95
+ active: false,
96
+ payload: [{ payload: { name: 'test', type: 'metric' } }],
97
+ })}
98
+ {/* payload empty → line 413 returns null */}
99
+ {content({ active: true, payload: [] })}
100
+ {/* item=null → line 415 returns null */}
101
+ {content({ active: true, payload: [{}] })}
102
+ {/* item.name exists + item.type → lines 416-421 */}
103
+ {content({
104
+ active: true,
105
+ payload: [{ payload: { name: 'a.b.metric', type: 'metric' } }],
106
+ })}
107
+ {/* item.name falsy → uses source→target fallback (line 418) */}
108
+ {content({
109
+ active: true,
110
+ payload: [
111
+ {
112
+ payload: {
113
+ source: { display_name: 'Src Node', name: 'src' },
114
+ target: { name: 'tgt' },
115
+ },
116
+ },
117
+ ],
118
+ })}
119
+ {/* item.name falsy, source has no display_name → uses source.name */}
120
+ {content({
121
+ active: true,
122
+ payload: [
123
+ {
124
+ payload: {
125
+ source: { name: 'src.node' },
126
+ target: { display_name: 'Target Display' },
127
+ },
128
+ },
129
+ ],
130
+ })}
131
+ </div>
132
+ );
133
+ },
134
+ };
135
+ });
136
+
137
+ // ResizeObserver fires callback immediately so containerWidth gets set to 800
138
+ beforeAll(() => {
139
+ global.ResizeObserver = function (callback) {
140
+ return {
141
+ observe: function (el) {
142
+ callback([{ contentRect: { width: 800 } }]);
143
+ },
144
+ disconnect: function () {},
145
+ };
146
+ };
147
+ HTMLCanvasElement.prototype.getContext = () => ({
148
+ font: '',
149
+ measureText: () => ({ width: 50 }),
150
+ });
151
+ });
152
+
153
+ describe('<NodeDataFlowTab />', () => {
154
+ const mockDjClient = {
155
+ upstreamsGQL: jest.fn(),
156
+ downstreamsGQL: jest.fn(),
157
+ findCubesWithMetrics: jest.fn(),
158
+ };
159
+
160
+ const renderWithContext = djNode =>
161
+ render(
162
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
163
+ <MemoryRouter>
164
+ <NodeDataFlowTab djNode={djNode} />
165
+ </MemoryRouter>
166
+ </DJClientContext.Provider>,
167
+ );
168
+
169
+ beforeEach(() => {
170
+ jest.clearAllMocks();
171
+ mockDjClient.findCubesWithMetrics.mockResolvedValue([]);
172
+ });
173
+
174
+ it('stays in loading state when djNode has no name (line 186 early return)', async () => {
175
+ renderWithContext({});
176
+ // Effect returns early, loading stays true — no API calls
177
+ expect(mockDjClient.upstreamsGQL).not.toHaveBeenCalled();
178
+ expect(
179
+ screen.queryByText('No data flow relationships found for this node.'),
180
+ ).not.toBeInTheDocument();
181
+ });
182
+
183
+ it('shows "No data flow relationships" when dag and downstreams return empty arrays', async () => {
184
+ // Use a source node — metrics get phantom links which make links.length > 0
185
+ const djNode = { name: 'default.source1', type: 'source', parents: [] };
186
+ mockDjClient.upstreamsGQL.mockResolvedValue([]);
187
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
188
+
189
+ renderWithContext(djNode);
190
+
191
+ await waitFor(() => {
192
+ expect(
193
+ screen.getByText('No data flow relationships found for this node.'),
194
+ ).toBeInTheDocument();
195
+ });
196
+ });
197
+
198
+ it('renders Sankey when links exist between nodes', async () => {
199
+ const source = {
200
+ name: 'default.source1',
201
+ type: 'source',
202
+ current: { parents: [] },
203
+ };
204
+ const djNode = {
205
+ name: 'default.metric1',
206
+ type: 'metric',
207
+ parents: [{ name: 'default.source1' }],
208
+ };
209
+ mockDjClient.upstreamsGQL.mockResolvedValue([source]);
210
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
211
+
212
+ renderWithContext(djNode);
213
+
214
+ await waitFor(() => {
215
+ expect(screen.getByTestId('sankey')).toBeInTheDocument();
216
+ });
217
+ });
218
+
219
+ it('handles error from Promise.all gracefully (lines 268-269)', async () => {
220
+ const consoleSpy = jest
221
+ .spyOn(console, 'error')
222
+ .mockImplementation(() => {});
223
+ const djNode = { name: 'default.metric1', type: 'metric', parents: [] };
224
+ mockDjClient.upstreamsGQL.mockRejectedValue(new Error('Network error'));
225
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
226
+
227
+ renderWithContext(djNode);
228
+
229
+ await waitFor(() => {
230
+ expect(consoleSpy).toHaveBeenCalledWith(expect.any(Error));
231
+ });
232
+ expect(
233
+ screen.getByText('No data flow relationships found for this node.'),
234
+ ).toBeInTheDocument();
235
+ consoleSpy.mockRestore();
236
+ });
237
+
238
+ it('filters CUBE type from downstream nodes and calls findCubesWithMetrics (lines 195-196)', async () => {
239
+ const djNode = {
240
+ name: 'default.metric1',
241
+ type: 'metric',
242
+ parents: [],
243
+ };
244
+ mockDjClient.upstreamsGQL.mockResolvedValue([]);
245
+ mockDjClient.downstreamsGQL.mockResolvedValue([
246
+ {
247
+ name: 'default.cube1',
248
+ type: 'CUBE',
249
+ current: { parents: [{ name: 'default.metric1' }] },
250
+ },
251
+ {
252
+ name: 'default.transform1',
253
+ type: 'TRANSFORM',
254
+ current: { parents: [{ name: 'default.metric1' }] },
255
+ },
256
+ ]);
257
+ // cube has metric as parent → creates a link
258
+ mockDjClient.findCubesWithMetrics.mockResolvedValue([
259
+ {
260
+ name: 'default.cube1',
261
+ type: 'cube',
262
+ parents: [{ name: 'default.metric1' }],
263
+ },
264
+ ]);
265
+
266
+ renderWithContext(djNode);
267
+
268
+ await waitFor(() => {
269
+ expect(mockDjClient.findCubesWithMetrics).toHaveBeenCalledWith([
270
+ 'default.cube1',
271
+ ]);
272
+ });
273
+
274
+ await waitFor(() => {
275
+ expect(screen.getByTestId('sankey')).toBeInTheDocument();
276
+ });
277
+ });
278
+
279
+ it('covers sort return 0 branch for same-type non-seed nodes (line 220)', async () => {
280
+ // Two source nodes, neither is the seed (metric1) — sort returns 0
281
+ const source1 = {
282
+ name: 'default.source1',
283
+ type: 'source',
284
+ current: { parents: [] },
285
+ };
286
+ const source2 = {
287
+ name: 'default.source2',
288
+ type: 'source',
289
+ current: { parents: [] },
290
+ };
291
+ const djNode = {
292
+ name: 'default.metric1',
293
+ type: 'metric',
294
+ parents: [{ name: 'default.source1' }, { name: 'default.source2' }],
295
+ };
296
+ mockDjClient.upstreamsGQL.mockResolvedValue([source1, source2]);
297
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
298
+
299
+ renderWithContext(djNode);
300
+
301
+ await waitFor(() => {
302
+ expect(screen.getByTestId('sankey')).toBeInTheDocument();
303
+ });
304
+ });
305
+
306
+ it('covers SankeyLink hover (linkHovered=true) via mouseEnter on path', async () => {
307
+ const source = {
308
+ name: 'default.source1',
309
+ type: 'source',
310
+ current: { parents: [] },
311
+ };
312
+ const djNode = {
313
+ name: 'default.metric1',
314
+ type: 'metric',
315
+ parents: [{ name: 'default.source1' }],
316
+ };
317
+ mockDjClient.upstreamsGQL.mockResolvedValue([source]);
318
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
319
+
320
+ const { container } = renderWithContext(djNode);
321
+
322
+ await waitFor(() => {
323
+ expect(screen.getByTestId('sankey')).toBeInTheDocument();
324
+ });
325
+
326
+ // Trigger linkHovered=true to cover the 0.85 opacity branch (line 137)
327
+ const path = container.querySelector('path');
328
+ if (path) {
329
+ fireEvent.mouseEnter(path);
330
+ fireEvent.mouseLeave(path);
331
+ }
332
+ });
333
+
334
+ it('covers ResizeObserver callback setting containerWidth (line 179)', async () => {
335
+ const djNode = {
336
+ name: 'default.metric1',
337
+ type: 'metric',
338
+ parents: [{ name: 'default.source1' }],
339
+ };
340
+ const source = {
341
+ name: 'default.source1',
342
+ type: 'source',
343
+ current: { parents: [] },
344
+ };
345
+ mockDjClient.upstreamsGQL.mockResolvedValue([source]);
346
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
347
+
348
+ renderWithContext(djNode);
349
+
350
+ // ResizeObserver mock fires immediately with width=800 → containerWidth>0 → Sankey renders
351
+ await waitFor(() => {
352
+ expect(screen.getByTestId('sankey')).toBeInTheDocument();
353
+ });
354
+ });
355
+
356
+ it('renders SankeyNode with rightmostType=metric when no cubes', async () => {
357
+ const source = {
358
+ name: 'default.source1',
359
+ type: 'source',
360
+ current: { parents: [] },
361
+ };
362
+ const transform = {
363
+ name: 'default.transform1',
364
+ type: 'transform',
365
+ current: { parents: [{ name: 'default.source1' }] },
366
+ };
367
+ const djNode = {
368
+ name: 'default.metric1',
369
+ type: 'metric',
370
+ parents: [{ name: 'default.transform1' }],
371
+ };
372
+ mockDjClient.upstreamsGQL.mockResolvedValue([source, transform]);
373
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
374
+
375
+ renderWithContext(djNode);
376
+
377
+ await waitFor(() => {
378
+ expect(screen.getByTestId('sankey')).toBeInTheDocument();
379
+ });
380
+ });
381
+
382
+ it('includes dimension nodes in the flow graph (they have data lineage like transforms)', async () => {
383
+ const dimNode = {
384
+ name: 'default.dim1',
385
+ type: 'dimension',
386
+ current: { parents: [{ name: 'default.source1' }] },
387
+ };
388
+ const djNode = {
389
+ name: 'default.metric1',
390
+ type: 'metric',
391
+ parents: [{ name: 'default.source1' }],
392
+ };
393
+ const source = {
394
+ name: 'default.source1',
395
+ type: 'source',
396
+ current: { parents: [] },
397
+ };
398
+ mockDjClient.upstreamsGQL.mockResolvedValue([dimNode, source]);
399
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
400
+
401
+ renderWithContext(djNode);
402
+
403
+ await waitFor(() => {
404
+ expect(screen.getByTestId('sankey')).toBeInTheDocument();
405
+ });
406
+ });
407
+
408
+ it('shows data flow for a dimension node viewed directly', async () => {
409
+ const djNode = {
410
+ name: 'default.dispatcher',
411
+ type: 'dimension',
412
+ parents: [{ name: 'default.dispatchers_source' }],
413
+ };
414
+ const source = {
415
+ name: 'default.dispatchers_source',
416
+ type: 'source',
417
+ current: { parents: [] },
418
+ };
419
+ mockDjClient.upstreamsGQL.mockResolvedValue([source]);
420
+ mockDjClient.downstreamsGQL.mockResolvedValue([]);
421
+
422
+ renderWithContext(djNode);
423
+
424
+ await waitFor(() => {
425
+ expect(screen.getByTestId('sankey')).toBeInTheDocument();
426
+ });
427
+ });
428
+ });
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { render, waitFor, screen } from '@testing-library/react';
3
- import NodeDependenciesTab from '../NodeDependenciesTab';
3
+ import NodeDependenciesTab, { NodeList } from '../NodeDependenciesTab';
4
4
 
5
5
  describe('<NodeDependenciesTab />', () => {
6
6
  const mockDjClient = {
@@ -135,6 +135,23 @@ describe('<NodeDependenciesTab />', () => {
135
135
  mockDjClient.downstreamsGQL.mockReset();
136
136
  });
137
137
 
138
+ it('skips fetching when node is null (line 12 false branch)', () => {
139
+ render(<NodeDependenciesTab node={null} djClient={mockDjClient} />);
140
+ expect(mockDjClient.upstreamsGQL).not.toHaveBeenCalled();
141
+ expect(mockDjClient.downstreamsGQL).not.toHaveBeenCalled();
142
+ expect(mockDjClient.nodeDimensions).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it('renders "None" for NodeList with empty array (lines 127-131)', () => {
146
+ render(<NodeList nodes={[]} />);
147
+ expect(screen.getByText('None')).toBeInTheDocument();
148
+ });
149
+
150
+ it('renders "None" for NodeList with null nodes', () => {
151
+ render(<NodeList nodes={null} />);
152
+ expect(screen.getByText('None')).toBeInTheDocument();
153
+ });
154
+
138
155
  it('renders nodes with dimensions', async () => {
139
156
  // Use mockResolvedValue since the component now uses .then()
140
157
  mockDjClient.nodeDimensions.mockResolvedValue(mockDimensions);