datajunction-ui 0.0.93 → 0.0.95

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 (42) 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 +412 -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/pages/QueryPlannerPage/ResultsView.jsx +420 -86
  33. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +32 -1
  34. package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +322 -0
  35. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +431 -2
  36. package/src/app/pages/QueryPlannerPage/index.jsx +31 -5
  37. package/src/app/pages/QueryPlannerPage/styles.css +211 -2
  38. package/src/app/pages/Root/__tests__/index.test.jsx +2 -3
  39. package/src/app/pages/Root/index.tsx +1 -1
  40. package/src/app/services/DJService.js +133 -23
  41. package/src/app/services/__tests__/DJService.test.jsx +600 -11
  42. package/src/styles/index.css +32 -0
@@ -0,0 +1,412 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import DJClientContext from '../../../providers/djclient';
5
+ import NodeDimensionsTab from '../NodeDimensionsTab';
6
+
7
+ // Mock reactflow — renders DimNode components directly to cover lines 42-136
8
+ jest.mock('reactflow', () => {
9
+ const { useState } = require('react');
10
+ const React = require('react');
11
+ return {
12
+ __esModule: true,
13
+ default: ({ nodes, nodeTypes }) => {
14
+ return (
15
+ <div data-testid="reactflow">
16
+ {(nodes || []).map(n => {
17
+ const NodeComp = nodeTypes?.[n.type];
18
+ return NodeComp ? (
19
+ <NodeComp key={n.id} data={n.data} />
20
+ ) : (
21
+ <div key={n.id}>{n.id}</div>
22
+ );
23
+ })}
24
+ </div>
25
+ );
26
+ },
27
+ useNodesState: initial => {
28
+ const [n, s] = useState(initial || []);
29
+ return [n, s, () => {}];
30
+ },
31
+ useEdgesState: initial => {
32
+ const [e, s] = useState(initial || []);
33
+ return [e, s, () => {}];
34
+ },
35
+ Handle: () => null,
36
+ MarkerType: { ArrowClosed: 'arrowclosed' },
37
+ Position: { Left: 'left', Right: 'right' },
38
+ addEdge: (params, eds) => [...(eds || []), params],
39
+ };
40
+ });
41
+
42
+ jest.mock('reactflow/dist/style.css', () => ({}));
43
+
44
+ jest.mock('dagre', () => {
45
+ function MockGraph() {
46
+ this.setDefaultEdgeLabel = function () {};
47
+ this.setGraph = function () {};
48
+ this.setNode = function () {};
49
+ this.setEdge = function () {};
50
+ this.node = function () {
51
+ return { x: 100, y: 100 };
52
+ };
53
+ }
54
+ return {
55
+ graphlib: { Graph: MockGraph },
56
+ layout: function () {},
57
+ };
58
+ });
59
+
60
+ describe('<NodeDimensionsTab />', () => {
61
+ const mockDjClient = {
62
+ dimensionDag: jest.fn(),
63
+ };
64
+
65
+ const renderWithContext = djNode =>
66
+ render(
67
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
68
+ <MemoryRouter>
69
+ <NodeDimensionsTab djNode={djNode} />
70
+ </MemoryRouter>
71
+ </DJClientContext.Provider>,
72
+ );
73
+
74
+ beforeEach(() => {
75
+ jest.clearAllMocks();
76
+ });
77
+
78
+ it('shows "No dimension links found" when dimensionDag returns empty', async () => {
79
+ const djNode = {
80
+ name: 'default.metric1',
81
+ type: 'metric',
82
+ parents: [],
83
+ dimension_links: [],
84
+ };
85
+ mockDjClient.dimensionDag.mockResolvedValue({
86
+ inbound: [],
87
+ inbound_edges: [],
88
+ outbound: [],
89
+ outbound_edges: [],
90
+ });
91
+
92
+ renderWithContext(djNode);
93
+
94
+ await waitFor(() => {
95
+ expect(
96
+ screen.getByText('No dimension links found for this node.'),
97
+ ).toBeInTheDocument();
98
+ });
99
+ });
100
+
101
+ it('does not fetch when djNode has no name (early return)', () => {
102
+ renderWithContext({ type: 'metric', parents: [], dimension_links: [] });
103
+ expect(mockDjClient.dimensionDag).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it('renders ReactFlow with DimNode components when outbound dimensions exist', async () => {
107
+ const djNode = {
108
+ name: 'default.metric1',
109
+ type: 'metric',
110
+ parents: [],
111
+ dimension_links: [],
112
+ };
113
+ const dimNode = {
114
+ name: 'default.dim1',
115
+ type: 'dimension',
116
+ display_name: 'Default: Dim 1',
117
+ };
118
+
119
+ mockDjClient.dimensionDag.mockResolvedValue({
120
+ inbound: [],
121
+ inbound_edges: [],
122
+ outbound: [dimNode],
123
+ outbound_edges: [{ source: 'default.metric1', target: 'default.dim1' }],
124
+ });
125
+
126
+ renderWithContext(djNode);
127
+
128
+ await waitFor(() => {
129
+ expect(screen.getByTestId('reactflow')).toBeInTheDocument();
130
+ });
131
+ expect(screen.getByText('dimension')).toBeInTheDocument();
132
+ });
133
+
134
+ it('handles error from dimensionDag gracefully', async () => {
135
+ const consoleSpy = jest
136
+ .spyOn(console, 'error')
137
+ .mockImplementation(() => {});
138
+ const djNode = {
139
+ name: 'default.metric1',
140
+ type: 'metric',
141
+ parents: [],
142
+ dimension_links: [],
143
+ };
144
+ mockDjClient.dimensionDag.mockRejectedValue(new Error('fetch failed'));
145
+
146
+ renderWithContext(djNode);
147
+
148
+ await waitFor(() => {
149
+ expect(consoleSpy).toHaveBeenCalledWith(expect.any(Error));
150
+ });
151
+ await waitFor(() => {
152
+ expect(
153
+ screen.getByText('No dimension links found for this node.'),
154
+ ).toBeInTheDocument();
155
+ });
156
+ consoleSpy.mockRestore();
157
+ });
158
+
159
+ it('covers DimNode isCurrent border style (current node renders correctly)', async () => {
160
+ const djNode = {
161
+ name: 'default.metric1',
162
+ type: 'metric',
163
+ parents: [],
164
+ dimension_links: [],
165
+ };
166
+
167
+ mockDjClient.dimensionDag.mockResolvedValue({
168
+ inbound: [],
169
+ inbound_edges: [],
170
+ outbound: [
171
+ { name: 'default.dim1', type: 'dimension', display_name: 'Dim 1' },
172
+ ],
173
+ outbound_edges: [{ source: 'default.metric1', target: 'default.dim1' }],
174
+ });
175
+
176
+ renderWithContext(djNode);
177
+
178
+ await waitFor(() => {
179
+ expect(screen.getByTestId('reactflow')).toBeInTheDocument();
180
+ });
181
+ // metric1 is the current node (isCurrent=true)
182
+ expect(screen.getByText('metric')).toBeInTheDocument();
183
+ });
184
+
185
+ it('covers DimNode with no namespace (node name has no dots)', async () => {
186
+ const djNode = {
187
+ name: 'metric1',
188
+ type: 'metric',
189
+ parents: [],
190
+ dimension_links: [],
191
+ };
192
+
193
+ mockDjClient.dimensionDag.mockResolvedValue({
194
+ inbound: [],
195
+ inbound_edges: [],
196
+ outbound: [{ name: 'dim1', type: 'dimension', display_name: 'Dim 1' }],
197
+ outbound_edges: [{ source: 'metric1', target: 'dim1' }],
198
+ });
199
+
200
+ renderWithContext(djNode);
201
+
202
+ await waitFor(() => {
203
+ expect(screen.getByTestId('reactflow')).toBeInTheDocument();
204
+ });
205
+ });
206
+
207
+ it('covers DimNode with unknown type (fallback color)', async () => {
208
+ const djNode = {
209
+ name: 'default.metric1',
210
+ type: 'metric',
211
+ parents: [],
212
+ dimension_links: [],
213
+ };
214
+
215
+ mockDjClient.dimensionDag.mockResolvedValue({
216
+ inbound: [],
217
+ inbound_edges: [],
218
+ outbound: [
219
+ {
220
+ name: 'default.custom_node',
221
+ type: 'unknown_type',
222
+ display_name: 'Custom Node',
223
+ },
224
+ ],
225
+ outbound_edges: [
226
+ { source: 'default.metric1', target: 'default.custom_node' },
227
+ ],
228
+ });
229
+
230
+ renderWithContext(djNode);
231
+
232
+ await waitFor(() => {
233
+ expect(screen.getByTestId('reactflow')).toBeInTheDocument();
234
+ });
235
+ expect(screen.getByText('unknown_type')).toBeInTheDocument();
236
+ });
237
+
238
+ it('renders dimension DAG for a dimension node (inbound + outbound)', async () => {
239
+ const djNode = {
240
+ name: 'default.hard_hat',
241
+ type: 'dimension',
242
+ display_name: 'Hard Hat',
243
+ parents: [],
244
+ dimension_links: [],
245
+ };
246
+ const inboundNode = {
247
+ name: 'default.repair_orders',
248
+ display_name: 'Repair Orders',
249
+ type: 'source',
250
+ };
251
+ const outboundNode = {
252
+ name: 'default.us_state',
253
+ display_name: 'US State',
254
+ type: 'dimension',
255
+ };
256
+
257
+ mockDjClient.dimensionDag.mockResolvedValue({
258
+ inbound: [inboundNode],
259
+ inbound_edges: [{ source: inboundNode.name, target: djNode.name }],
260
+ outbound: [outboundNode],
261
+ outbound_edges: [{ source: djNode.name, target: outboundNode.name }],
262
+ });
263
+
264
+ renderWithContext(djNode);
265
+
266
+ await waitFor(() => {
267
+ expect(mockDjClient.dimensionDag).toHaveBeenCalledWith(
268
+ 'default.hard_hat',
269
+ );
270
+ });
271
+ await waitFor(() => {
272
+ expect(screen.getByTestId('reactflow')).toBeInTheDocument();
273
+ });
274
+ });
275
+
276
+ it('shows "No dimension links found" when dimension DAG returns empty inbound + outbound', async () => {
277
+ const djNode = {
278
+ name: 'default.payment_type',
279
+ type: 'dimension',
280
+ display_name: 'Payment Type',
281
+ parents: [],
282
+ dimension_links: [],
283
+ };
284
+ mockDjClient.dimensionDag.mockResolvedValue({
285
+ inbound: [],
286
+ inbound_edges: [],
287
+ outbound: [],
288
+ outbound_edges: [],
289
+ });
290
+
291
+ renderWithContext(djNode);
292
+
293
+ await waitFor(() => {
294
+ expect(
295
+ screen.getByText('No dimension links found for this node.'),
296
+ ).toBeInTheDocument();
297
+ });
298
+ });
299
+
300
+ it('handles dimensionDag error gracefully', async () => {
301
+ const consoleSpy = jest
302
+ .spyOn(console, 'error')
303
+ .mockImplementation(() => {});
304
+ const djNode = {
305
+ name: 'default.hard_hat',
306
+ type: 'dimension',
307
+ parents: [],
308
+ dimension_links: [],
309
+ };
310
+ mockDjClient.dimensionDag.mockRejectedValue(new Error('network error'));
311
+
312
+ renderWithContext(djNode);
313
+
314
+ await waitFor(() => {
315
+ expect(consoleSpy).toHaveBeenCalledWith(expect.any(Error));
316
+ });
317
+ await waitFor(() => {
318
+ expect(
319
+ screen.getByText('No dimension links found for this node.'),
320
+ ).toBeInTheDocument();
321
+ });
322
+ consoleSpy.mockRestore();
323
+ });
324
+
325
+ it('renders cube dimension graph via dimensionDag (backend handles cube expansion)', async () => {
326
+ const djNode = {
327
+ name: 'default.repairs_cube',
328
+ type: 'cube',
329
+ parents: [],
330
+ dimension_links: [],
331
+ };
332
+
333
+ // The backend now seeds the BFS from the cube's metric upstreams, so the
334
+ // frontend just calls dimensionDag normally and gets back a populated result.
335
+ mockDjClient.dimensionDag.mockResolvedValue({
336
+ inbound: [
337
+ {
338
+ name: 'default.repair_orders',
339
+ type: 'source',
340
+ display_name: 'Repair Orders',
341
+ },
342
+ ],
343
+ inbound_edges: [
344
+ { source: 'default.repair_orders', target: 'default.hard_hat' },
345
+ ],
346
+ outbound: [
347
+ {
348
+ name: 'default.hard_hat',
349
+ type: 'dimension',
350
+ display_name: 'Hard Hat',
351
+ },
352
+ {
353
+ name: 'default.dispatcher',
354
+ type: 'dimension',
355
+ display_name: 'Dispatcher',
356
+ },
357
+ ],
358
+ outbound_edges: [
359
+ { source: 'default.repair_orders', target: 'default.hard_hat' },
360
+ { source: 'default.repair_orders', target: 'default.dispatcher' },
361
+ ],
362
+ });
363
+
364
+ renderWithContext(djNode);
365
+
366
+ await waitFor(() => {
367
+ expect(screen.getByTestId('reactflow')).toBeInTheDocument();
368
+ });
369
+ expect(mockDjClient.dimensionDag).toHaveBeenCalledWith(
370
+ 'default.repairs_cube',
371
+ );
372
+ expect(screen.getAllByText('dimension').length).toBeGreaterThan(0);
373
+ });
374
+
375
+ it('renders multi-level inbound chain (non-flat graph)', async () => {
376
+ const djNode = {
377
+ name: 'default.us_state',
378
+ type: 'dimension',
379
+ display_name: 'US State',
380
+ parents: [],
381
+ dimension_links: [],
382
+ };
383
+
384
+ mockDjClient.dimensionDag.mockResolvedValue({
385
+ inbound: [
386
+ {
387
+ name: 'default.hard_hat',
388
+ type: 'dimension',
389
+ display_name: 'Hard Hat',
390
+ },
391
+ {
392
+ name: 'default.repair_order',
393
+ type: 'source',
394
+ display_name: 'Repair Order',
395
+ },
396
+ ],
397
+ inbound_edges: [
398
+ { source: 'default.hard_hat', target: 'default.us_state' },
399
+ { source: 'default.repair_order', target: 'default.hard_hat' },
400
+ ],
401
+ outbound: [],
402
+ outbound_edges: [],
403
+ });
404
+
405
+ renderWithContext(djNode);
406
+
407
+ await waitFor(() => {
408
+ expect(screen.getByTestId('reactflow')).toBeInTheDocument();
409
+ });
410
+ expect(screen.getAllByText('dimension').length).toBeGreaterThan(0);
411
+ });
412
+ });
@@ -14,6 +14,23 @@ jest.mock('cronstrue', () => ({
14
14
  // Mock CSS imports
15
15
  jest.mock('../../../../styles/preaggregations.css', () => ({}));
16
16
 
17
+ // Mock recharts for NodeDataFlowTab (Sankey doesn't work in jsdom)
18
+ jest.mock('recharts', () => ({
19
+ Sankey: () => <div data-testid="sankey-chart" />,
20
+ Tooltip: () => null,
21
+ }));
22
+
23
+ // ResizeObserver is not defined in jsdom
24
+ global.ResizeObserver = function (callback) {
25
+ return { observe: () => {}, disconnect: () => {}, unobserve: () => {} };
26
+ };
27
+
28
+ // jsdom doesn't implement canvas — stub getContext so text-measurement code doesn't crash
29
+ HTMLCanvasElement.prototype.getContext = () => ({
30
+ font: '',
31
+ measureText: () => ({ width: 0 }),
32
+ });
33
+
17
34
  describe('<NodePage />', () => {
18
35
  const domTestingLib = require('@testing-library/dom');
19
36
  const { queryHelpers } = domTestingLib;
@@ -71,6 +88,12 @@ describe('<NodePage />', () => {
71
88
  .mockResolvedValue({ status: 200 }),
72
89
  listPreaggs: jest.fn().mockResolvedValue({ items: [] }),
73
90
  deactivatePreaggWorkflow: jest.fn().mockResolvedValue({ status: 200 }),
91
+ namespaceSources: jest.fn().mockResolvedValue(null),
92
+ listDeployments: jest.fn().mockResolvedValue([]),
93
+ getNamespaceGitConfig: jest.fn().mockResolvedValue(null),
94
+ upstreamsGQL: jest.fn().mockResolvedValue([]),
95
+ downstreamsGQL: jest.fn().mockResolvedValue([]),
96
+ findCubesWithMetrics: jest.fn().mockResolvedValue([]),
74
97
  },
75
98
  };
76
99
  };
@@ -729,15 +752,17 @@ describe('<NodePage />', () => {
729
752
  </DJClientContext.Provider>
730
753
  );
731
754
  render(
732
- <MemoryRouter initialEntries={['/nodes/default.num_repair_orders/graph']}>
755
+ <MemoryRouter
756
+ initialEntries={['/nodes/default.num_repair_orders/data-flow']}
757
+ >
733
758
  <Routes>
734
759
  <Route path="nodes/:name/:tab" element={element} />
735
760
  </Routes>
736
761
  </MemoryRouter>,
737
762
  );
738
763
  await waitFor(() => {
739
- fireEvent.click(screen.getByRole('button', { name: 'Graph' }));
740
- expect(djClient.DataJunctionAPI.node_dag).toHaveBeenCalledWith(
764
+ fireEvent.click(screen.getByRole('button', { name: 'Data Flow' }));
765
+ expect(djClient.DataJunctionAPI.upstreamsGQL).toHaveBeenCalledWith(
741
766
  mocks.mockMetricNode.name,
742
767
  );
743
768
  });
@@ -164,10 +164,10 @@ describe('<NodesWithDimension />', () => {
164
164
  );
165
165
  for (const node of mockNodesWithDimension) {
166
166
  // renders nodes based on nodesWithDimension data
167
- expect(screen.getByText(node.display_name)).toBeInTheDocument();
167
+ expect(screen.getByText(node.name)).toBeInTheDocument();
168
168
 
169
169
  // renders links to the correct URLs
170
- const link = screen.getByText(node.display_name).closest('a');
170
+ const link = screen.getByText(node.name).closest('a');
171
171
  expect(link).toHaveAttribute('href', `/nodes/${node.name}`);
172
172
  }
173
173
  });
@@ -5,7 +5,8 @@ import Tab from '../../components/Tab';
5
5
  import NamespaceHeader from '../../components/NamespaceHeader';
6
6
  import NodeInfoTab from './NodeInfoTab';
7
7
  import NodeColumnTab from './NodeColumnTab';
8
- import NodeGraphTab from './NodeGraphTab';
8
+ import NodeDataFlowTab from './NodeDataFlowTab';
9
+ import NodeDimensionsTab from './NodeDimensionsTab';
9
10
  import NodeHistory from './NodeHistory';
10
11
  import NotebookDownload from './NotebookDownload';
11
12
  import DJClientContext from '../../providers/djclient';
@@ -83,10 +84,15 @@ export function NodePage() {
83
84
  display: node?.type !== 'metric',
84
85
  },
85
86
  {
86
- id: 'graph',
87
- name: 'Graph',
87
+ id: 'data-flow',
88
+ name: 'Data Flow',
88
89
  display: true,
89
90
  },
91
+ {
92
+ id: 'dimensions',
93
+ name: 'Dimensions',
94
+ display: node?.type !== 'source',
95
+ },
90
96
  {
91
97
  id: 'history',
92
98
  name: 'History',
@@ -128,8 +134,11 @@ export function NodePage() {
128
134
  case 'columns':
129
135
  tabToDisplay = <NodeColumnTab node={node} djClient={djClient} />;
130
136
  break;
131
- case 'graph':
132
- tabToDisplay = <NodeGraphTab djNode={node} djClient={djClient} />;
137
+ case 'data-flow':
138
+ tabToDisplay = <NodeDataFlowTab djNode={node} />;
139
+ break;
140
+ case 'dimensions':
141
+ tabToDisplay = <NodeDimensionsTab djNode={node} />;
133
142
  break;
134
143
  case 'history':
135
144
  tabToDisplay = <NodeHistory node={node} djClient={djClient} />;
@@ -261,9 +270,7 @@ export function NodePage() {
261
270
  {node?.version}
262
271
  </span>
263
272
  </div>
264
- <div className="align-items-center row">
265
- {tabsList(node).map(buildTabs)}
266
- </div>
273
+ <div className="dj-tabs-bar">{tabsList(node).map(buildTabs)}</div>
267
274
  {tabToDisplay}
268
275
  </div>
269
276
  ) : node?.message !== undefined ? (