datajunction-ui 0.0.21 → 0.0.23-dev2
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 +11 -4
- package/src/app/components/NotificationBell.tsx +11 -5
- package/src/app/components/UserMenu.tsx +3 -11
- package/src/app/components/__tests__/NotificationBell.test.tsx +17 -6
- package/src/app/components/__tests__/UserMenu.test.tsx +10 -3
- package/src/app/index.tsx +92 -85
- package/src/app/pages/AddEditNodePage/OwnersField.jsx +2 -3
- package/src/app/pages/NamespacePage/index.jsx +2 -4
- package/src/app/pages/NodePage/ClientCodePopover.jsx +27 -5
- package/src/app/pages/NodePage/NodeDependenciesTab.jsx +47 -45
- package/src/app/pages/NodePage/NodeInfoTab.jsx +73 -15
- package/src/app/pages/NodePage/__tests__/NodeDependenciesTab.test.jsx +13 -7
- package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +23 -13
- package/src/app/pages/NodePage/index.jsx +14 -19
- package/src/app/pages/SettingsPage/__tests__/index.test.jsx +4 -1
- package/src/app/pages/SettingsPage/index.jsx +6 -6
- package/src/app/providers/UserProvider.tsx +78 -0
- package/src/app/services/DJService.js +43 -0
- package/src/app/services/__tests__/DJService.test.jsx +76 -0
- package/src/styles/nav-bar.css +1 -1
- package/webpack.config.js +3 -1
|
@@ -4,53 +4,51 @@ import { labelize } from '../../../utils/form';
|
|
|
4
4
|
import LoadingIcon from '../../icons/LoadingIcon';
|
|
5
5
|
|
|
6
6
|
export default function NodeDependenciesTab({ node, djClient }) {
|
|
7
|
-
const [
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
dimensions: [],
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
const [retrieved, setRetrieved] = useState(false);
|
|
7
|
+
const [upstreams, setUpstreams] = useState(null);
|
|
8
|
+
const [downstreams, setDownstreams] = useState(null);
|
|
9
|
+
const [dimensions, setDimensions] = useState(null);
|
|
14
10
|
|
|
15
11
|
useEffect(() => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
12
|
+
if (node) {
|
|
13
|
+
// Reset state when node changes
|
|
14
|
+
setUpstreams(null);
|
|
15
|
+
setDownstreams(null);
|
|
16
|
+
setDimensions(null);
|
|
17
|
+
|
|
18
|
+
// Load all three in parallel, display each as it loads
|
|
19
|
+
djClient.upstreamsGQL(node.name).then(setUpstreams).catch(console.error);
|
|
20
|
+
djClient
|
|
21
|
+
.downstreamsGQL(node.name)
|
|
22
|
+
.then(setDownstreams)
|
|
23
|
+
.catch(console.error);
|
|
24
|
+
djClient
|
|
25
|
+
.nodeDimensions(node.name)
|
|
26
|
+
.then(setDimensions)
|
|
27
|
+
.catch(console.error);
|
|
28
|
+
}
|
|
30
29
|
}, [djClient, node]);
|
|
31
30
|
|
|
32
|
-
// Builds the block of dimensions selectors, grouped by node name + path
|
|
33
31
|
return (
|
|
34
32
|
<div>
|
|
35
33
|
<h2>Upstreams</h2>
|
|
36
|
-
{
|
|
37
|
-
<NodeList nodes={
|
|
34
|
+
{upstreams !== null ? (
|
|
35
|
+
<NodeList nodes={upstreams} />
|
|
38
36
|
) : (
|
|
39
37
|
<span style={{ display: 'block' }}>
|
|
40
38
|
<LoadingIcon centered={false} />
|
|
41
39
|
</span>
|
|
42
40
|
)}
|
|
43
41
|
<h2>Downstreams</h2>
|
|
44
|
-
{
|
|
45
|
-
<NodeList nodes={
|
|
42
|
+
{downstreams !== null ? (
|
|
43
|
+
<NodeList nodes={downstreams} />
|
|
46
44
|
) : (
|
|
47
45
|
<span style={{ display: 'block' }}>
|
|
48
46
|
<LoadingIcon centered={false} />
|
|
49
47
|
</span>
|
|
50
48
|
)}
|
|
51
49
|
<h2>Dimensions</h2>
|
|
52
|
-
{
|
|
53
|
-
<NodeDimensionsList rawDimensions={
|
|
50
|
+
{dimensions !== null ? (
|
|
51
|
+
<NodeDimensionsList rawDimensions={dimensions} />
|
|
54
52
|
) : (
|
|
55
53
|
<span style={{ display: 'block' }}>
|
|
56
54
|
<LoadingIcon centered={false} />
|
|
@@ -128,24 +126,28 @@ export function NodeDimensionsList({ rawDimensions }) {
|
|
|
128
126
|
export function NodeList({ nodes }) {
|
|
129
127
|
return nodes && nodes.length > 0 ? (
|
|
130
128
|
<ul className="backfills">
|
|
131
|
-
{nodes?.map(node =>
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
style={{ marginRight: '5px' }}
|
|
140
|
-
role="dialog"
|
|
141
|
-
aria-hidden="false"
|
|
142
|
-
aria-label="NodeType"
|
|
129
|
+
{nodes?.map(node => {
|
|
130
|
+
// GraphQL returns uppercase types (e.g., "SOURCE"), CSS classes expect lowercase
|
|
131
|
+
const nodeType = node.type?.toLowerCase() || '';
|
|
132
|
+
return (
|
|
133
|
+
<li
|
|
134
|
+
className="backfill"
|
|
135
|
+
style={{ marginBottom: '5px' }}
|
|
136
|
+
key={node.name}
|
|
143
137
|
>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
138
|
+
<span
|
|
139
|
+
className={`node_type__${nodeType} badge node_type`}
|
|
140
|
+
style={{ marginRight: '5px' }}
|
|
141
|
+
role="dialog"
|
|
142
|
+
aria-hidden="false"
|
|
143
|
+
aria-label="NodeType"
|
|
144
|
+
>
|
|
145
|
+
{nodeType}
|
|
146
|
+
</span>
|
|
147
|
+
<a href={`/nodes/${node.name}`}>{node.name}</a>
|
|
148
|
+
</li>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
149
151
|
</ul>
|
|
150
152
|
) : (
|
|
151
153
|
<span style={{ display: 'block' }}>None</span>
|
|
@@ -11,9 +11,31 @@ import { labelize } from '../../../utils/form';
|
|
|
11
11
|
SyntaxHighlighter.registerLanguage('sql', sql);
|
|
12
12
|
foundation.hljs['padding'] = '2rem';
|
|
13
13
|
|
|
14
|
+
// interface MetricInfo {
|
|
15
|
+
// name: string;
|
|
16
|
+
// current: MetricRevision;
|
|
17
|
+
// }
|
|
18
|
+
|
|
19
|
+
// interface MetricRevision {
|
|
20
|
+
// parents: array<string>;
|
|
21
|
+
// metricMetadata:
|
|
22
|
+
// }
|
|
23
|
+
|
|
24
|
+
// interface MetricMetadata {
|
|
25
|
+
// direction: string;
|
|
26
|
+
// unit: string;
|
|
27
|
+
// expression: string;
|
|
28
|
+
// significantDigits: string;
|
|
29
|
+
// incompatibleDruidFunctions: array<string>;
|
|
30
|
+
// }
|
|
31
|
+
|
|
14
32
|
export default function NodeInfoTab({ node }) {
|
|
15
33
|
const [compiledSQL, setCompiledSQL] = useState('');
|
|
16
34
|
const [checked, setChecked] = useState(false);
|
|
35
|
+
|
|
36
|
+
// For metrics
|
|
37
|
+
const [metricInfo, setMetricInfo] = useState(null);
|
|
38
|
+
|
|
17
39
|
const nodeTags = node?.tags.map(tag => (
|
|
18
40
|
<div className={'badge tag_value'}>
|
|
19
41
|
<a href={`/tags/${tag.name}`}>{tag.display_name}</a>
|
|
@@ -36,17 +58,49 @@ export default function NodeInfoTab({ node }) {
|
|
|
36
58
|
};
|
|
37
59
|
fetchData().catch(console.error);
|
|
38
60
|
}, [node, djClient, checked]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const fetchData = async () => {
|
|
64
|
+
const metric = await djClient.getMetric(node.name);
|
|
65
|
+
setMetricInfo({
|
|
66
|
+
metric_metadata: metric.current.metricMetadata,
|
|
67
|
+
required_dimensions: metric.current.requiredDimensions,
|
|
68
|
+
upstream_node: metric.current.parents[0]?.name,
|
|
69
|
+
expression: metric.current.metricMetadata?.expression,
|
|
70
|
+
incompatible_druid_functions:
|
|
71
|
+
metric.current.metricMetadata?.incompatibleDruidFunctions || [],
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
if (node.type === 'metric') {
|
|
75
|
+
fetchData().catch(console.error);
|
|
76
|
+
}
|
|
77
|
+
}, [node, djClient]);
|
|
78
|
+
|
|
79
|
+
// For cubes
|
|
80
|
+
const [cubeElements, setCubeElements] = useState(null);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const fetchData = async () => {
|
|
84
|
+
const cube = await djClient.cube(node.name);
|
|
85
|
+
setCubeElements(cube.cube_elements);
|
|
86
|
+
};
|
|
87
|
+
if (node.type === 'cube') {
|
|
88
|
+
fetchData().catch(console.error);
|
|
89
|
+
}
|
|
90
|
+
}, [node, djClient]);
|
|
91
|
+
|
|
39
92
|
function toggle(value) {
|
|
40
93
|
return !value;
|
|
41
94
|
}
|
|
42
95
|
const metricsWarning =
|
|
43
|
-
node?.type === 'metric' &&
|
|
96
|
+
node?.type === 'metric' &&
|
|
97
|
+
metricInfo?.incompatible_druid_functions?.length > 0 ? (
|
|
44
98
|
<div className="message warning" style={{ marginTop: '0.7rem' }}>
|
|
45
99
|
⚠{' '}
|
|
46
100
|
<small>
|
|
47
101
|
The following functions used in the metric definition may not be
|
|
48
102
|
compatible with Druid SQL:{' '}
|
|
49
|
-
{
|
|
103
|
+
{metricInfo?.incompatible_druid_functions.map(func => (
|
|
50
104
|
<li
|
|
51
105
|
style={{ listStyleType: 'none', margin: '0.7rem 0.7rem' }}
|
|
52
106
|
key={func}
|
|
@@ -79,15 +133,15 @@ export default function NodeInfoTab({ node }) {
|
|
|
79
133
|
<div style={{ marginBottom: '30px' }}>
|
|
80
134
|
<h6 className="mb-0 w-100">Upstream Node</h6>
|
|
81
135
|
<p>
|
|
82
|
-
<a href={`/nodes/${
|
|
83
|
-
{
|
|
136
|
+
<a href={`/nodes/${metricInfo?.upstream_node}`}>
|
|
137
|
+
{metricInfo?.upstream_node}
|
|
84
138
|
</a>
|
|
85
139
|
</p>
|
|
86
140
|
</div>
|
|
87
141
|
<div>
|
|
88
142
|
<h6 className="mb-0 w-100">Aggregate Expression</h6>
|
|
89
143
|
<SyntaxHighlighter language="sql" style={foundation}>
|
|
90
|
-
{
|
|
144
|
+
{metricInfo?.expression}
|
|
91
145
|
</SyntaxHighlighter>
|
|
92
146
|
</div>
|
|
93
147
|
</div>
|
|
@@ -163,8 +217,10 @@ export default function NodeInfoTab({ node }) {
|
|
|
163
217
|
aria-hidden="false"
|
|
164
218
|
aria-label="MetricDirection"
|
|
165
219
|
>
|
|
166
|
-
{
|
|
167
|
-
? labelize(
|
|
220
|
+
{metricInfo?.metric_metadata?.direction
|
|
221
|
+
? labelize(
|
|
222
|
+
metricInfo?.metric_metadata?.direction?.toLowerCase(),
|
|
223
|
+
)
|
|
168
224
|
: 'None'}
|
|
169
225
|
</p>
|
|
170
226
|
</div>
|
|
@@ -176,8 +232,10 @@ export default function NodeInfoTab({ node }) {
|
|
|
176
232
|
aria-hidden="false"
|
|
177
233
|
aria-label="MetricUnit"
|
|
178
234
|
>
|
|
179
|
-
{
|
|
180
|
-
? labelize(
|
|
235
|
+
{metricInfo?.metric_metadata?.unit?.name
|
|
236
|
+
? labelize(
|
|
237
|
+
metricInfo?.metric_metadata?.unit?.name?.toLowerCase(),
|
|
238
|
+
)
|
|
181
239
|
: 'None'}
|
|
182
240
|
</p>
|
|
183
241
|
</div>
|
|
@@ -189,7 +247,7 @@ export default function NodeInfoTab({ node }) {
|
|
|
189
247
|
aria-hidden="false"
|
|
190
248
|
aria-label="SignificantDigits"
|
|
191
249
|
>
|
|
192
|
-
{
|
|
250
|
+
{metricInfo?.metric_metadata?.significantDigits || 'None'}
|
|
193
251
|
</p>
|
|
194
252
|
</div>
|
|
195
253
|
</div>
|
|
@@ -218,7 +276,7 @@ export default function NodeInfoTab({ node }) {
|
|
|
218
276
|
''
|
|
219
277
|
);
|
|
220
278
|
|
|
221
|
-
const cubeElementsDiv =
|
|
279
|
+
const cubeElementsDiv = cubeElements ? (
|
|
222
280
|
<div className="list-group-item d-flex">
|
|
223
281
|
<div className="d-flex gap-2 w-100 justify-content-between py-3">
|
|
224
282
|
<div
|
|
@@ -228,10 +286,10 @@ export default function NodeInfoTab({ node }) {
|
|
|
228
286
|
>
|
|
229
287
|
<h6 className="mb-0 w-100">Cube Elements</h6>
|
|
230
288
|
<div className={`list-group-item`}>
|
|
231
|
-
{
|
|
289
|
+
{cubeElements.map(cubeElem =>
|
|
232
290
|
cubeElem.type === 'metric' ? displayCubeElement(cubeElem) : '',
|
|
233
291
|
)}
|
|
234
|
-
{
|
|
292
|
+
{cubeElements.map(cubeElem =>
|
|
235
293
|
cubeElem.type !== 'metric' ? displayCubeElement(cubeElem) : '',
|
|
236
294
|
)}
|
|
237
295
|
</div>
|
|
@@ -375,8 +433,8 @@ export default function NodeInfoTab({ node }) {
|
|
|
375
433
|
</div>
|
|
376
434
|
</div>
|
|
377
435
|
{metricMetadataDiv}
|
|
378
|
-
{node
|
|
379
|
-
{node
|
|
436
|
+
{node.type !== 'cube' && node.type !== 'metric' ? queryDiv : ''}
|
|
437
|
+
{node.type === 'metric' ? metricQueryDiv : ''}
|
|
380
438
|
{customMetadataDiv}
|
|
381
439
|
{cubeElementsDiv}
|
|
382
440
|
</div>
|
|
@@ -6,8 +6,8 @@ describe('<NodeDependenciesTab />', () => {
|
|
|
6
6
|
const mockDjClient = {
|
|
7
7
|
node: jest.fn(),
|
|
8
8
|
nodeDimensions: jest.fn(),
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
upstreamsGQL: jest.fn(),
|
|
10
|
+
downstreamsGQL: jest.fn(),
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
const mockNode = {
|
|
@@ -131,14 +131,20 @@ describe('<NodeDependenciesTab />', () => {
|
|
|
131
131
|
beforeEach(() => {
|
|
132
132
|
// Reset the mocks before each test
|
|
133
133
|
mockDjClient.nodeDimensions.mockReset();
|
|
134
|
-
mockDjClient.
|
|
135
|
-
mockDjClient.
|
|
134
|
+
mockDjClient.upstreamsGQL.mockReset();
|
|
135
|
+
mockDjClient.downstreamsGQL.mockReset();
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
it('renders nodes with dimensions', async () => {
|
|
139
|
-
|
|
140
|
-
mockDjClient.
|
|
141
|
-
|
|
139
|
+
// Use mockResolvedValue since the component now uses .then()
|
|
140
|
+
mockDjClient.nodeDimensions.mockResolvedValue(mockDimensions);
|
|
141
|
+
// GraphQL responses return uppercase types (e.g., "SOURCE")
|
|
142
|
+
mockDjClient.upstreamsGQL.mockResolvedValue([
|
|
143
|
+
{ name: mockNode.name, type: 'SOURCE' },
|
|
144
|
+
]);
|
|
145
|
+
mockDjClient.downstreamsGQL.mockResolvedValue([
|
|
146
|
+
{ name: mockNode.name, type: 'SOURCE' },
|
|
147
|
+
]);
|
|
142
148
|
render(<NodeDependenciesTab node={mockNode} djClient={mockDjClient} />);
|
|
143
149
|
await waitFor(() => {
|
|
144
150
|
for (const dimension of mockDimensions) {
|
|
@@ -26,7 +26,7 @@ describe('<NodePage />', () => {
|
|
|
26
26
|
getMetric: jest.fn(),
|
|
27
27
|
revalidate: jest.fn().mockReturnValue({ status: 'valid' }),
|
|
28
28
|
node_dag: jest.fn().mockReturnValue(mocks.mockNodeDAG),
|
|
29
|
-
clientCode: jest.fn().
|
|
29
|
+
clientCode: jest.fn().mockResolvedValue('dj_client = DJClient()'),
|
|
30
30
|
columns: jest.fn(),
|
|
31
31
|
history: jest.fn(),
|
|
32
32
|
revisions: jest.fn(),
|
|
@@ -298,7 +298,7 @@ describe('<NodePage />', () => {
|
|
|
298
298
|
it('renders the NodeInfo tab correctly for a metric node', async () => {
|
|
299
299
|
const djClient = mockDJClient();
|
|
300
300
|
djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
|
|
301
|
-
djClient.DataJunctionAPI.getMetric.
|
|
301
|
+
djClient.DataJunctionAPI.getMetric.mockResolvedValue(
|
|
302
302
|
mocks.mockMetricNodeJson,
|
|
303
303
|
);
|
|
304
304
|
const element = (
|
|
@@ -351,17 +351,27 @@ describe('<NodePage />', () => {
|
|
|
351
351
|
expect(
|
|
352
352
|
screen.getByRole('dialog', { name: 'NodeType' }),
|
|
353
353
|
).toHaveTextContent('metric');
|
|
354
|
+
});
|
|
354
355
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
).
|
|
356
|
+
// Wait separately for getMetric to be called and data to render
|
|
357
|
+
await waitFor(() => {
|
|
358
|
+
expect(djClient.DataJunctionAPI.getMetric).toHaveBeenCalledWith(
|
|
359
|
+
'default.num_repair_orders',
|
|
360
|
+
);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Wait for metric expression to appear (SyntaxHighlighter may split text)
|
|
364
|
+
await waitFor(() => {
|
|
365
|
+
expect(screen.getByText(/count/)).toBeInTheDocument();
|
|
358
366
|
});
|
|
367
|
+
|
|
368
|
+
expect(container.getElementsByClassName('language-sql')).toMatchSnapshot();
|
|
359
369
|
}, 60000);
|
|
360
370
|
|
|
361
371
|
it('renders the NodeInfo tab correctly for cube nodes', async () => {
|
|
362
372
|
const djClient = mockDJClient();
|
|
363
373
|
djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockCubeNode);
|
|
364
|
-
djClient.DataJunctionAPI.cube.
|
|
374
|
+
djClient.DataJunctionAPI.cube.mockResolvedValue(mocks.mockCubesCube);
|
|
365
375
|
const element = (
|
|
366
376
|
<DJClientContext.Provider value={djClient}>
|
|
367
377
|
<NodePage {...defaultProps} />
|
|
@@ -420,7 +430,7 @@ describe('<NodePage />', () => {
|
|
|
420
430
|
it('renders the NodeColumns tab correctly', async () => {
|
|
421
431
|
const djClient = mockDJClient();
|
|
422
432
|
djClient.DataJunctionAPI.node.mockResolvedValue(mocks.mockMetricNode);
|
|
423
|
-
djClient.DataJunctionAPI.getMetric.
|
|
433
|
+
djClient.DataJunctionAPI.getMetric.mockResolvedValue(
|
|
424
434
|
mocks.mockMetricNodeJson,
|
|
425
435
|
);
|
|
426
436
|
djClient.DataJunctionAPI.columns.mockResolvedValue(mocks.metricNodeColumns);
|
|
@@ -448,7 +458,7 @@ describe('<NodePage />', () => {
|
|
|
448
458
|
);
|
|
449
459
|
await waitFor(() => {
|
|
450
460
|
expect(djClient.DataJunctionAPI.columns).toHaveBeenCalledWith(
|
|
451
|
-
mocks.mockMetricNode,
|
|
461
|
+
expect.objectContaining({ name: mocks.mockMetricNode.name }),
|
|
452
462
|
);
|
|
453
463
|
expect(
|
|
454
464
|
screen.getByRole('columnheader', { name: 'ColumnName' }),
|
|
@@ -498,7 +508,7 @@ describe('<NodePage />', () => {
|
|
|
498
508
|
it('renders the NodeHistory tab correctly', async () => {
|
|
499
509
|
const djClient = mockDJClient();
|
|
500
510
|
djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
|
|
501
|
-
djClient.DataJunctionAPI.getMetric.
|
|
511
|
+
djClient.DataJunctionAPI.getMetric.mockResolvedValue(
|
|
502
512
|
mocks.mockMetricNodeJson,
|
|
503
513
|
);
|
|
504
514
|
djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
|
|
@@ -600,7 +610,7 @@ describe('<NodePage />', () => {
|
|
|
600
610
|
it('renders an empty NodeMaterialization tab correctly', async () => {
|
|
601
611
|
const djClient = mockDJClient();
|
|
602
612
|
djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
|
|
603
|
-
djClient.DataJunctionAPI.getMetric.
|
|
613
|
+
djClient.DataJunctionAPI.getMetric.mockResolvedValue(
|
|
604
614
|
mocks.mockMetricNodeJson,
|
|
605
615
|
);
|
|
606
616
|
djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
|
|
@@ -640,7 +650,7 @@ describe('<NodePage />', () => {
|
|
|
640
650
|
it('renders the NodeMaterialization tab with materializations correctly', async () => {
|
|
641
651
|
const djClient = mockDJClient();
|
|
642
652
|
djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockTransformNode);
|
|
643
|
-
djClient.DataJunctionAPI.getMetric.
|
|
653
|
+
djClient.DataJunctionAPI.getMetric.mockResolvedValue(
|
|
644
654
|
mocks.mockMetricNodeJson,
|
|
645
655
|
);
|
|
646
656
|
djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
|
|
@@ -795,7 +805,7 @@ describe('<NodePage />', () => {
|
|
|
795
805
|
it('renders a NodeColumnLineage tab correctly', async () => {
|
|
796
806
|
const djClient = mockDJClient();
|
|
797
807
|
djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
|
|
798
|
-
djClient.DataJunctionAPI.getMetric.
|
|
808
|
+
djClient.DataJunctionAPI.getMetric.mockResolvedValue(
|
|
799
809
|
mocks.mockMetricNodeJson,
|
|
800
810
|
);
|
|
801
811
|
djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
|
|
@@ -828,7 +838,7 @@ describe('<NodePage />', () => {
|
|
|
828
838
|
it('renders a NodeGraph tab correctly', async () => {
|
|
829
839
|
const djClient = mockDJClient();
|
|
830
840
|
djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
|
|
831
|
-
djClient.DataJunctionAPI.getMetric.
|
|
841
|
+
djClient.DataJunctionAPI.getMetric.mockResolvedValue(
|
|
832
842
|
mocks.mockMetricNodeJson,
|
|
833
843
|
);
|
|
834
844
|
djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
|
|
@@ -17,6 +17,7 @@ import NodesWithDimension from './NodesWithDimension';
|
|
|
17
17
|
import NodeColumnLineage from './NodeLineageTab';
|
|
18
18
|
import EditIcon from '../../icons/EditIcon';
|
|
19
19
|
import AlertIcon from '../../icons/AlertIcon';
|
|
20
|
+
import LoadingIcon from '../../icons/LoadingIcon';
|
|
20
21
|
import NodeDependenciesTab from './NodeDependenciesTab';
|
|
21
22
|
import { useNavigate } from 'react-router-dom';
|
|
22
23
|
|
|
@@ -30,7 +31,7 @@ export function NodePage() {
|
|
|
30
31
|
selectedTab: tab || 'info',
|
|
31
32
|
});
|
|
32
33
|
|
|
33
|
-
const [node, setNode] = useState();
|
|
34
|
+
const [node, setNode] = useState(null);
|
|
34
35
|
|
|
35
36
|
const onClickTab = id => () => {
|
|
36
37
|
navigate(`/nodes/${name}/${id}`);
|
|
@@ -52,21 +53,12 @@ export function NodePage() {
|
|
|
52
53
|
useEffect(() => {
|
|
53
54
|
const fetchData = async () => {
|
|
54
55
|
const data = await djClient.node(name);
|
|
55
|
-
data.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
data.required_dimensions = metric.current.requiredDimensions;
|
|
60
|
-
data.upstream_node = metric.current.parents[0].name;
|
|
61
|
-
data.expression = metric.current.metricMetadata.expression;
|
|
62
|
-
data.incompatible_druid_functions =
|
|
63
|
-
metric.current.metricMetadata.incompatibleDruidFunctions;
|
|
56
|
+
if (data.message !== undefined) {
|
|
57
|
+
// Error response
|
|
58
|
+
setNode(data);
|
|
59
|
+
return;
|
|
64
60
|
}
|
|
65
|
-
|
|
66
|
-
const cube = await djClient.cube(name);
|
|
67
|
-
data.cube_elements = cube.cube_elements;
|
|
68
|
-
}
|
|
69
|
-
setNode(data);
|
|
61
|
+
setNode({ ...data });
|
|
70
62
|
};
|
|
71
63
|
fetchData().catch(console.error);
|
|
72
64
|
}, [djClient, name]);
|
|
@@ -124,8 +116,7 @@ export function NodePage() {
|
|
|
124
116
|
|
|
125
117
|
switch (state.selectedTab) {
|
|
126
118
|
case 'info':
|
|
127
|
-
tabToDisplay =
|
|
128
|
-
node && node.message === undefined ? <NodeInfoTab node={node} /> : '';
|
|
119
|
+
tabToDisplay = node ? <NodeInfoTab node={node} /> : '';
|
|
129
120
|
break;
|
|
130
121
|
case 'columns':
|
|
131
122
|
tabToDisplay = <NodeColumnTab node={node} djClient={djClient} />;
|
|
@@ -168,7 +159,7 @@ export function NodePage() {
|
|
|
168
159
|
|
|
169
160
|
<WatchButton node={node} />
|
|
170
161
|
|
|
171
|
-
<ClientCodePopover
|
|
162
|
+
<ClientCodePopover nodeName={name} />
|
|
172
163
|
{node?.type === 'cube' && <NotebookDownload node={node} />}
|
|
173
164
|
</div>
|
|
174
165
|
);
|
|
@@ -179,7 +170,11 @@ export function NodePage() {
|
|
|
179
170
|
<div className="node__header">
|
|
180
171
|
<NamespaceHeader namespace={name.split('.').slice(0, -1).join('.')} />
|
|
181
172
|
<div className="card">
|
|
182
|
-
{node
|
|
173
|
+
{node === undefined ? (
|
|
174
|
+
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
175
|
+
<LoadingIcon />
|
|
176
|
+
</div>
|
|
177
|
+
) : node?.message === undefined ? (
|
|
183
178
|
<div className="card-header" style={{}}>
|
|
184
179
|
<div
|
|
185
180
|
style={{
|
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
3
|
import { SettingsPage } from '../index';
|
|
4
4
|
import DJClientContext from '../../../providers/djclient';
|
|
5
|
+
import { UserProvider } from '../../../providers/UserProvider';
|
|
5
6
|
|
|
6
7
|
describe('SettingsPage', () => {
|
|
7
8
|
const mockDjClient = {
|
|
@@ -18,7 +19,9 @@ describe('SettingsPage', () => {
|
|
|
18
19
|
const renderWithContext = () => {
|
|
19
20
|
return render(
|
|
20
21
|
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
21
|
-
<
|
|
22
|
+
<UserProvider>
|
|
23
|
+
<SettingsPage />
|
|
24
|
+
</UserProvider>
|
|
22
25
|
</DJClientContext.Provider>,
|
|
23
26
|
);
|
|
24
27
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useContext, useEffect, useState } from 'react';
|
|
2
2
|
import DJClientContext from '../../providers/djclient';
|
|
3
|
+
import { useCurrentUser } from '../../providers/UserProvider';
|
|
3
4
|
import LoadingIcon from '../../icons/LoadingIcon';
|
|
4
5
|
import ProfileSection from './ProfileSection';
|
|
5
6
|
import NotificationSubscriptionsSection from './NotificationSubscriptionsSection';
|
|
@@ -11,18 +12,17 @@ import '../../../styles/settings.css';
|
|
|
11
12
|
*/
|
|
12
13
|
export function SettingsPage() {
|
|
13
14
|
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
14
|
-
const
|
|
15
|
+
const { currentUser, loading: userLoading } = useCurrentUser();
|
|
15
16
|
const [subscriptions, setSubscriptions] = useState([]);
|
|
16
17
|
const [serviceAccounts, setServiceAccounts] = useState([]);
|
|
17
18
|
const [loading, setLoading] = useState(true);
|
|
18
19
|
|
|
19
20
|
useEffect(() => {
|
|
21
|
+
// Wait for user to be loaded from context
|
|
22
|
+
if (userLoading) return;
|
|
23
|
+
|
|
20
24
|
async function fetchData() {
|
|
21
25
|
try {
|
|
22
|
-
// Fetch user profile
|
|
23
|
-
const user = await djClient.whoami();
|
|
24
|
-
setCurrentUser(user);
|
|
25
|
-
|
|
26
26
|
// Fetch notification subscriptions
|
|
27
27
|
const prefs = await djClient.getNotificationPreferences();
|
|
28
28
|
|
|
@@ -69,7 +69,7 @@ export function SettingsPage() {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
fetchData();
|
|
72
|
-
}, [djClient]);
|
|
72
|
+
}, [djClient, userLoading]);
|
|
73
73
|
|
|
74
74
|
// Subscription handlers
|
|
75
75
|
const handleUpdateSubscription = async (sub, activityTypes) => {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useCallback,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import DJClientContext from './djclient';
|
|
10
|
+
|
|
11
|
+
interface User {
|
|
12
|
+
id?: number;
|
|
13
|
+
username?: string;
|
|
14
|
+
email?: string;
|
|
15
|
+
name?: string;
|
|
16
|
+
last_viewed_notifications_at?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UserContextType {
|
|
20
|
+
currentUser: User | null;
|
|
21
|
+
loading: boolean;
|
|
22
|
+
error: Error | null;
|
|
23
|
+
refetchUser: () => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const UserContext = createContext<UserContextType>({
|
|
27
|
+
currentUser: null,
|
|
28
|
+
loading: true,
|
|
29
|
+
error: null,
|
|
30
|
+
refetchUser: async () => {},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export function UserProvider({ children }: { children: React.ReactNode }) {
|
|
34
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
35
|
+
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
|
36
|
+
const [loading, setLoading] = useState(true);
|
|
37
|
+
const [error, setError] = useState<Error | null>(null);
|
|
38
|
+
|
|
39
|
+
const fetchUser = useCallback(async () => {
|
|
40
|
+
try {
|
|
41
|
+
setLoading(true);
|
|
42
|
+
setError(null);
|
|
43
|
+
const user = await djClient.whoami();
|
|
44
|
+
setCurrentUser(user);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
setError(err instanceof Error ? err : new Error('Failed to fetch user'));
|
|
47
|
+
console.error('Error fetching user:', err);
|
|
48
|
+
} finally {
|
|
49
|
+
setLoading(false);
|
|
50
|
+
}
|
|
51
|
+
}, [djClient]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
fetchUser();
|
|
55
|
+
}, [fetchUser]);
|
|
56
|
+
|
|
57
|
+
const value = useMemo(
|
|
58
|
+
() => ({
|
|
59
|
+
currentUser,
|
|
60
|
+
loading,
|
|
61
|
+
error,
|
|
62
|
+
refetchUser: fetchUser,
|
|
63
|
+
}),
|
|
64
|
+
[currentUser, loading, error, fetchUser],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useCurrentUser() {
|
|
71
|
+
const context = useContext(UserContext);
|
|
72
|
+
if (context === undefined) {
|
|
73
|
+
throw new Error('useCurrentUser must be used within a UserProvider');
|
|
74
|
+
}
|
|
75
|
+
return context;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export default UserContext;
|