datajunction-ui 0.0.26-alpha.0 → 0.0.27
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 +2 -2
- package/src/app/components/Search.jsx +41 -33
- package/src/app/components/__tests__/Search.test.jsx +46 -11
- package/src/app/index.tsx +1 -1
- package/src/app/pages/AddEditNodePage/MetricQueryField.jsx +57 -8
- package/src/app/pages/AddEditNodePage/UpstreamNodeField.jsx +17 -5
- package/src/app/pages/AddEditNodePage/__tests__/index.test.jsx +97 -1
- package/src/app/pages/AddEditNodePage/index.jsx +61 -17
- package/src/app/pages/NodePage/WatchNodeButton.jsx +12 -5
- package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +93 -15
- package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +2320 -65
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +234 -25
- package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +315 -122
- package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +2672 -314
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +567 -0
- package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +480 -55
- package/src/app/pages/QueryPlannerPage/index.jsx +1021 -14
- package/src/app/pages/QueryPlannerPage/styles.css +1990 -62
- package/src/app/pages/Root/__tests__/index.test.jsx +79 -8
- package/src/app/pages/Root/index.tsx +1 -1
- package/src/app/pages/SQLBuilderPage/__tests__/index.test.jsx +82 -0
- package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +37 -0
- package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +48 -0
- package/src/app/pages/SettingsPage/__tests__/index.test.jsx +169 -1
- package/src/app/services/DJService.js +492 -3
- package/src/app/services/__tests__/DJService.test.jsx +582 -0
- package/src/mocks/mockNodes.jsx +36 -0
- package/webpack.config.js +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "datajunction-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.27",
|
|
4
4
|
"description": "DataJunction Metrics Platform UI",
|
|
5
5
|
"module": "src/index.tsx",
|
|
6
6
|
"repository": {
|
|
@@ -167,7 +167,7 @@
|
|
|
167
167
|
"coverageThreshold": {
|
|
168
168
|
"global": {
|
|
169
169
|
"statements": 80,
|
|
170
|
-
"branches":
|
|
170
|
+
"branches": 69,
|
|
171
171
|
"lines": 80,
|
|
172
172
|
"functions": 80
|
|
173
173
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState,
|
|
1
|
+
import { useState, useCallback, useContext, useRef } from 'react';
|
|
2
2
|
import DJClientContext from '../providers/djclient';
|
|
3
3
|
import Fuse from 'fuse.js';
|
|
4
4
|
|
|
@@ -8,6 +8,8 @@ export default function Search() {
|
|
|
8
8
|
const [fuse, setFuse] = useState();
|
|
9
9
|
const [searchValue, setSearchValue] = useState('');
|
|
10
10
|
const [searchResults, setSearchResults] = useState([]);
|
|
11
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
12
|
+
const hasLoadedRef = useRef(false);
|
|
11
13
|
|
|
12
14
|
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
13
15
|
|
|
@@ -18,35 +20,40 @@ export default function Search() {
|
|
|
18
20
|
return str.length > 100 ? str.substring(0, 90) + '...' : str;
|
|
19
21
|
};
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
23
|
+
// Lazy load search data only when user focuses on search input
|
|
24
|
+
const loadSearchData = useCallback(async () => {
|
|
25
|
+
if (hasLoadedRef.current || isLoading) return;
|
|
26
|
+
hasLoadedRef.current = true;
|
|
27
|
+
setIsLoading(true);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const [data, tags] = await Promise.all([
|
|
31
|
+
djClient.nodeDetails(),
|
|
32
|
+
djClient.listTags(),
|
|
33
|
+
]);
|
|
34
|
+
const allEntities = data.concat(
|
|
35
|
+
(tags || []).map(tag => {
|
|
36
|
+
tag.type = 'tag';
|
|
37
|
+
return tag;
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const fuseInstance = new Fuse(allEntities || [], {
|
|
41
|
+
keys: [
|
|
42
|
+
'name', // will be assigned a `weight` of 1
|
|
43
|
+
{ name: 'description', weight: 2 },
|
|
44
|
+
{ name: 'display_name', weight: 3 },
|
|
45
|
+
{ name: 'type', weight: 4 },
|
|
46
|
+
{ name: 'tag_type', weight: 5 },
|
|
47
|
+
],
|
|
48
|
+
});
|
|
49
|
+
setFuse(fuseInstance);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Error fetching nodes or tags:', error);
|
|
52
|
+
hasLoadedRef.current = false; // Allow retry on error
|
|
53
|
+
} finally {
|
|
54
|
+
setIsLoading(false);
|
|
55
|
+
}
|
|
56
|
+
}, [djClient, isLoading]);
|
|
50
57
|
|
|
51
58
|
const handleChange = e => {
|
|
52
59
|
setSearchValue(e.target.value);
|
|
@@ -65,10 +72,11 @@ export default function Search() {
|
|
|
65
72
|
>
|
|
66
73
|
<input
|
|
67
74
|
type="text"
|
|
68
|
-
placeholder=
|
|
75
|
+
placeholder={isLoading ? 'Loading...' : 'Search'}
|
|
69
76
|
name="search"
|
|
70
77
|
value={searchValue}
|
|
71
78
|
onChange={handleChange}
|
|
79
|
+
onFocus={loadSearchData}
|
|
72
80
|
/>
|
|
73
81
|
</form>
|
|
74
82
|
<div className="search-results">
|
|
@@ -76,8 +84,8 @@ export default function Search() {
|
|
|
76
84
|
const itemUrl =
|
|
77
85
|
item.type !== 'tag' ? `/nodes/${item.name}` : `/tags/${item.name}`;
|
|
78
86
|
return (
|
|
79
|
-
<a href={itemUrl}>
|
|
80
|
-
<div
|
|
87
|
+
<a key={item.name} href={itemUrl}>
|
|
88
|
+
<div className="search-result-item">
|
|
81
89
|
<span className={`node_type__${item.type} badge node_type`}>
|
|
82
90
|
{item.type}
|
|
83
91
|
</span>
|
|
@@ -59,16 +59,23 @@ describe('<Search />', () => {
|
|
|
59
59
|
expect(getByPlaceholderText('Search')).toBeInTheDocument();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
it('fetches and initializes search data on
|
|
62
|
+
it('fetches and initializes search data on focus (lazy loading)', async () => {
|
|
63
63
|
mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue(mockNodes);
|
|
64
64
|
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue(mockTags);
|
|
65
65
|
|
|
66
|
-
render(
|
|
66
|
+
const { getByPlaceholderText } = render(
|
|
67
67
|
<DJClientContext.Provider value={mockDjClient}>
|
|
68
68
|
<Search />
|
|
69
69
|
</DJClientContext.Provider>,
|
|
70
70
|
);
|
|
71
71
|
|
|
72
|
+
// Data should NOT be fetched on mount
|
|
73
|
+
expect(mockDjClient.DataJunctionAPI.nodeDetails).not.toHaveBeenCalled();
|
|
74
|
+
|
|
75
|
+
// Focus on search input to trigger lazy loading
|
|
76
|
+
const searchInput = getByPlaceholderText('Search');
|
|
77
|
+
fireEvent.focus(searchInput);
|
|
78
|
+
|
|
72
79
|
await waitFor(() => {
|
|
73
80
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
74
81
|
expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
|
|
@@ -85,11 +92,14 @@ describe('<Search />', () => {
|
|
|
85
92
|
</DJClientContext.Provider>,
|
|
86
93
|
);
|
|
87
94
|
|
|
95
|
+
const searchInput = getByPlaceholderText('Search');
|
|
96
|
+
// Focus to trigger lazy loading
|
|
97
|
+
fireEvent.focus(searchInput);
|
|
98
|
+
|
|
88
99
|
await waitFor(() => {
|
|
89
100
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
90
101
|
});
|
|
91
102
|
|
|
92
|
-
const searchInput = getByPlaceholderText('Search');
|
|
93
103
|
fireEvent.change(searchInput, { target: { value: 'test' } });
|
|
94
104
|
|
|
95
105
|
await waitFor(() => {
|
|
@@ -107,11 +117,14 @@ describe('<Search />', () => {
|
|
|
107
117
|
</DJClientContext.Provider>,
|
|
108
118
|
);
|
|
109
119
|
|
|
120
|
+
const searchInput = getByPlaceholderText('Search');
|
|
121
|
+
// Focus to trigger lazy loading
|
|
122
|
+
fireEvent.focus(searchInput);
|
|
123
|
+
|
|
110
124
|
await waitFor(() => {
|
|
111
125
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
112
126
|
});
|
|
113
127
|
|
|
114
|
-
const searchInput = getByPlaceholderText('Search');
|
|
115
128
|
fireEvent.change(searchInput, { target: { value: 'node' } });
|
|
116
129
|
|
|
117
130
|
await waitFor(() => {
|
|
@@ -130,11 +143,14 @@ describe('<Search />', () => {
|
|
|
130
143
|
</DJClientContext.Provider>,
|
|
131
144
|
);
|
|
132
145
|
|
|
146
|
+
const searchInput = getByPlaceholderText('Search');
|
|
147
|
+
// Focus to trigger lazy loading
|
|
148
|
+
fireEvent.focus(searchInput);
|
|
149
|
+
|
|
133
150
|
await waitFor(() => {
|
|
134
151
|
expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
|
|
135
152
|
});
|
|
136
153
|
|
|
137
|
-
const searchInput = getByPlaceholderText('Search');
|
|
138
154
|
fireEvent.change(searchInput, { target: { value: 'tag' } });
|
|
139
155
|
|
|
140
156
|
await waitFor(() => {
|
|
@@ -153,11 +169,14 @@ describe('<Search />', () => {
|
|
|
153
169
|
</DJClientContext.Provider>,
|
|
154
170
|
);
|
|
155
171
|
|
|
172
|
+
const searchInput = getByPlaceholderText('Search');
|
|
173
|
+
// Focus to trigger lazy loading
|
|
174
|
+
fireEvent.focus(searchInput);
|
|
175
|
+
|
|
156
176
|
await waitFor(() => {
|
|
157
177
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
158
178
|
});
|
|
159
179
|
|
|
160
|
-
const searchInput = getByPlaceholderText('Search');
|
|
161
180
|
fireEvent.change(searchInput, { target: { value: 'long' } });
|
|
162
181
|
|
|
163
182
|
await waitFor(() => {
|
|
@@ -175,11 +194,14 @@ describe('<Search />', () => {
|
|
|
175
194
|
</DJClientContext.Provider>,
|
|
176
195
|
);
|
|
177
196
|
|
|
197
|
+
const searchInput = getByPlaceholderText('Search');
|
|
198
|
+
// Focus to trigger lazy loading
|
|
199
|
+
fireEvent.focus(searchInput);
|
|
200
|
+
|
|
178
201
|
await waitFor(() => {
|
|
179
202
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
180
203
|
});
|
|
181
204
|
|
|
182
|
-
const searchInput = getByPlaceholderText('Search');
|
|
183
205
|
fireEvent.change(searchInput, { target: { value: 'another' } });
|
|
184
206
|
|
|
185
207
|
await waitFor(() => {
|
|
@@ -204,11 +226,14 @@ describe('<Search />', () => {
|
|
|
204
226
|
</DJClientContext.Provider>,
|
|
205
227
|
);
|
|
206
228
|
|
|
229
|
+
const searchInput = getByPlaceholderText('Search');
|
|
230
|
+
// Focus to trigger lazy loading
|
|
231
|
+
fireEvent.focus(searchInput);
|
|
232
|
+
|
|
207
233
|
await waitFor(() => {
|
|
208
234
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
209
235
|
});
|
|
210
236
|
|
|
211
|
-
const searchInput = getByPlaceholderText('Search');
|
|
212
237
|
fireEvent.change(searchInput, { target: { value: 'node' } });
|
|
213
238
|
|
|
214
239
|
await waitFor(() => {
|
|
@@ -224,12 +249,16 @@ describe('<Search />', () => {
|
|
|
224
249
|
);
|
|
225
250
|
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
|
|
226
251
|
|
|
227
|
-
render(
|
|
252
|
+
const { getByPlaceholderText } = render(
|
|
228
253
|
<DJClientContext.Provider value={mockDjClient}>
|
|
229
254
|
<Search />
|
|
230
255
|
</DJClientContext.Provider>,
|
|
231
256
|
);
|
|
232
257
|
|
|
258
|
+
// Focus to trigger lazy loading
|
|
259
|
+
const searchInput = getByPlaceholderText('Search');
|
|
260
|
+
fireEvent.focus(searchInput);
|
|
261
|
+
|
|
233
262
|
await waitFor(() => {
|
|
234
263
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
235
264
|
'Error fetching nodes or tags:',
|
|
@@ -273,12 +302,15 @@ describe('<Search />', () => {
|
|
|
273
302
|
</DJClientContext.Provider>,
|
|
274
303
|
);
|
|
275
304
|
|
|
305
|
+
const searchInput = getByPlaceholderText('Search');
|
|
306
|
+
// Focus to trigger lazy loading
|
|
307
|
+
fireEvent.focus(searchInput);
|
|
308
|
+
|
|
276
309
|
await waitFor(() => {
|
|
277
310
|
expect(mockDjClient.DataJunctionAPI.listTags).toHaveBeenCalled();
|
|
278
311
|
});
|
|
279
312
|
|
|
280
313
|
// Should not throw an error
|
|
281
|
-
const searchInput = getByPlaceholderText('Search');
|
|
282
314
|
expect(searchInput).toBeInTheDocument();
|
|
283
315
|
});
|
|
284
316
|
|
|
@@ -292,11 +324,14 @@ describe('<Search />', () => {
|
|
|
292
324
|
</DJClientContext.Provider>,
|
|
293
325
|
);
|
|
294
326
|
|
|
327
|
+
const searchInput = getByPlaceholderText('Search');
|
|
328
|
+
// Focus to trigger lazy loading
|
|
329
|
+
fireEvent.focus(searchInput);
|
|
330
|
+
|
|
295
331
|
await waitFor(() => {
|
|
296
332
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).toHaveBeenCalled();
|
|
297
333
|
});
|
|
298
334
|
|
|
299
|
-
const searchInput = getByPlaceholderText('Search');
|
|
300
335
|
fireEvent.change(searchInput, { target: { value: 'test' } });
|
|
301
336
|
|
|
302
337
|
await waitFor(() => {
|
package/src/app/index.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { NamespacePage } from './pages/NamespacePage/Loadable';
|
|
|
11
11
|
import { OverviewPage } from './pages/OverviewPage/Loadable';
|
|
12
12
|
import { SettingsPage } from './pages/SettingsPage/Loadable';
|
|
13
13
|
import { NotificationsPage } from './pages/NotificationsPage/Loadable';
|
|
14
|
-
import { NodePage } from './pages/NodePage';
|
|
14
|
+
import { NodePage } from './pages/NodePage/Loadable';
|
|
15
15
|
import RevisionDiff from './pages/NodePage/RevisionDiff';
|
|
16
16
|
import { SQLBuilderPage } from './pages/SQLBuilderPage/Loadable';
|
|
17
17
|
import { CubeBuilderPage } from './pages/CubeBuilderPage/Loadable';
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Metric aggregate expression input field, which consists of a CodeMirror SQL
|
|
3
3
|
* editor with autocompletion for node columns and syntax highlighting.
|
|
4
|
+
*
|
|
5
|
+
* Supports both:
|
|
6
|
+
* - Regular metrics: autocomplete from upstream node columns
|
|
7
|
+
* - Derived metrics: autocomplete from available metric names
|
|
4
8
|
*/
|
|
5
9
|
import React from 'react';
|
|
6
10
|
import { ErrorMessage, Field, useFormikContext } from 'formik';
|
|
@@ -8,29 +12,74 @@ import CodeMirror from '@uiw/react-codemirror';
|
|
|
8
12
|
import { langs } from '@uiw/codemirror-extensions-langs';
|
|
9
13
|
|
|
10
14
|
export const MetricQueryField = ({ djClient, value }) => {
|
|
11
|
-
const [schema, setSchema] = React.useState(
|
|
15
|
+
const [schema, setSchema] = React.useState({});
|
|
16
|
+
const [availableMetrics, setAvailableMetrics] = React.useState([]);
|
|
12
17
|
const formik = useFormikContext();
|
|
13
18
|
const sqlExt = langs.sql({ schema: schema });
|
|
14
19
|
|
|
20
|
+
// Load available metrics for derived metric autocomplete
|
|
21
|
+
React.useEffect(() => {
|
|
22
|
+
async function fetchMetrics() {
|
|
23
|
+
try {
|
|
24
|
+
const metrics = await djClient.metrics();
|
|
25
|
+
setAvailableMetrics(metrics || []);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error('Failed to load metrics for autocomplete:', err);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
fetchMetrics();
|
|
31
|
+
}, [djClient]);
|
|
32
|
+
|
|
15
33
|
const initialAutocomplete = async context => {
|
|
16
|
-
|
|
17
|
-
// into the autocomplete schema
|
|
34
|
+
const newSchema = {};
|
|
18
35
|
const nodeName = formik.values['upstream_node'];
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
36
|
+
|
|
37
|
+
// If an upstream node is selected, load its columns for regular metrics
|
|
38
|
+
if (nodeName && nodeName.trim() !== '') {
|
|
39
|
+
try {
|
|
40
|
+
const nodeDetails = await djClient.node(nodeName);
|
|
41
|
+
if (nodeDetails && nodeDetails.columns) {
|
|
42
|
+
nodeDetails.columns.forEach(col => {
|
|
43
|
+
newSchema[col.name] = [];
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error('Failed to load upstream node columns:', err);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Always include available metrics for derived metric expressions
|
|
52
|
+
availableMetrics.forEach(metricName => {
|
|
53
|
+
newSchema[metricName] = [];
|
|
22
54
|
});
|
|
23
|
-
|
|
55
|
+
|
|
56
|
+
setSchema(newSchema);
|
|
24
57
|
};
|
|
25
58
|
|
|
26
59
|
const updateFormik = val => {
|
|
27
60
|
formik.setFieldValue('aggregate_expression', val);
|
|
28
61
|
};
|
|
29
62
|
|
|
63
|
+
// Determine the label and help text based on whether upstream is selected
|
|
64
|
+
const upstreamNode = formik.values['upstream_node'];
|
|
65
|
+
const isDerivedMode = !upstreamNode || upstreamNode.trim() === '';
|
|
66
|
+
const labelText = isDerivedMode
|
|
67
|
+
? 'Derived Metric Expression *'
|
|
68
|
+
: 'Aggregate Expression *';
|
|
69
|
+
const helpText = isDerivedMode
|
|
70
|
+
? 'Reference other metrics using their full names (e.g., namespace.metric_name / namespace.other_metric)'
|
|
71
|
+
: 'Use aggregate functions on columns from the upstream node (e.g., SUM(column_name))';
|
|
72
|
+
|
|
30
73
|
return (
|
|
31
74
|
<div className="QueryInput MetricQueryInput NodeCreationInput">
|
|
32
75
|
<ErrorMessage name="query" component="span" />
|
|
33
|
-
<label htmlFor="Query">
|
|
76
|
+
<label htmlFor="Query">{labelText}</label>
|
|
77
|
+
<p
|
|
78
|
+
className="field-help-text"
|
|
79
|
+
style={{ fontSize: '0.85em', color: '#666', marginBottom: '8px' }}
|
|
80
|
+
>
|
|
81
|
+
{helpText}
|
|
82
|
+
</p>
|
|
34
83
|
<Field
|
|
35
84
|
type="textarea"
|
|
36
85
|
style={{ display: 'none' }}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Upstream node select field
|
|
2
|
+
* Upstream node select field.
|
|
3
|
+
*
|
|
4
|
+
* For regular metrics: Select a source, transform, or dimension node.
|
|
5
|
+
* For derived metrics: Leave empty and reference other metrics directly in the expression.
|
|
3
6
|
*/
|
|
4
7
|
import { ErrorMessage } from 'formik';
|
|
5
8
|
import { useContext, useEffect, useState } from 'react';
|
|
@@ -9,7 +12,7 @@ import { FormikSelect } from './FormikSelect';
|
|
|
9
12
|
export const UpstreamNodeField = ({ defaultValue }) => {
|
|
10
13
|
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
11
14
|
|
|
12
|
-
// All available nodes
|
|
15
|
+
// All available nodes (sources, transforms, dimensions)
|
|
13
16
|
const [availableNodes, setAvailableNodes] = useState([]);
|
|
14
17
|
|
|
15
18
|
useEffect(() => {
|
|
@@ -32,16 +35,25 @@ export const UpstreamNodeField = ({ defaultValue }) => {
|
|
|
32
35
|
|
|
33
36
|
return (
|
|
34
37
|
<div className="NodeCreationInput">
|
|
35
|
-
<ErrorMessage name="
|
|
36
|
-
<label htmlFor="
|
|
38
|
+
<ErrorMessage name="upstream_node" component="span" />
|
|
39
|
+
<label htmlFor="upstream_node">Upstream Node</label>
|
|
40
|
+
<p
|
|
41
|
+
className="field-help-text"
|
|
42
|
+
style={{ fontSize: '0.85em', color: '#666', marginBottom: '8px' }}
|
|
43
|
+
>
|
|
44
|
+
Select a source, transform, or dimension for regular metrics. Leave
|
|
45
|
+
empty for <strong>derived metrics</strong> that reference other metrics
|
|
46
|
+
(e.g., <code>namespace.metric_a / namespace.metric_b</code>).
|
|
47
|
+
</p>
|
|
37
48
|
<span data-testid="select-upstream-node">
|
|
38
49
|
<FormikSelect
|
|
39
50
|
className="SelectInput"
|
|
40
51
|
defaultValue={defaultValue}
|
|
41
52
|
selectOptions={availableNodes}
|
|
42
53
|
formikFieldName="upstream_node"
|
|
43
|
-
placeholder="Select Upstream Node"
|
|
54
|
+
placeholder="Select Upstream Node (optional for derived metrics)"
|
|
44
55
|
isMulti={false}
|
|
56
|
+
isClearable={true}
|
|
45
57
|
/>
|
|
46
58
|
</span>
|
|
47
59
|
</div>
|
|
@@ -34,7 +34,12 @@ export const initializeMockDJClient = () => {
|
|
|
34
34
|
},
|
|
35
35
|
];
|
|
36
36
|
},
|
|
37
|
-
metrics:
|
|
37
|
+
metrics: jest
|
|
38
|
+
.fn()
|
|
39
|
+
.mockReturnValue([
|
|
40
|
+
'default.num_repair_orders',
|
|
41
|
+
'default.some_other_metric',
|
|
42
|
+
]),
|
|
38
43
|
getNodeForEditing: jest.fn(),
|
|
39
44
|
namespaces: () => {
|
|
40
45
|
return [
|
|
@@ -134,6 +139,16 @@ export const renderEditTransformNode = element => {
|
|
|
134
139
|
);
|
|
135
140
|
};
|
|
136
141
|
|
|
142
|
+
export const renderEditDerivedMetricNode = element => {
|
|
143
|
+
return render(
|
|
144
|
+
<MemoryRouter initialEntries={['/nodes/default.revenue_per_order/edit']}>
|
|
145
|
+
<Routes>
|
|
146
|
+
<Route path="nodes/:name/edit" element={element} />
|
|
147
|
+
</Routes>
|
|
148
|
+
</MemoryRouter>,
|
|
149
|
+
);
|
|
150
|
+
};
|
|
151
|
+
|
|
137
152
|
describe('AddEditNodePage', () => {
|
|
138
153
|
beforeEach(() => {
|
|
139
154
|
fetchMock.resetMocks();
|
|
@@ -221,4 +236,85 @@ describe('AddEditNodePage', () => {
|
|
|
221
236
|
).toBeInTheDocument();
|
|
222
237
|
});
|
|
223
238
|
}, 60000);
|
|
239
|
+
|
|
240
|
+
it('Edit page renders correctly for derived metric (metric parent)', async () => {
|
|
241
|
+
const mockDjClient = initializeMockDJClient();
|
|
242
|
+
mockDjClient.DataJunctionAPI.getNodeForEditing.mockReturnValue(
|
|
243
|
+
mocks.mockGetDerivedMetricNode,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const element = testElement(mockDjClient);
|
|
247
|
+
renderEditDerivedMetricNode(element);
|
|
248
|
+
|
|
249
|
+
await waitFor(() => {
|
|
250
|
+
// Should be an edit node page
|
|
251
|
+
expect(screen.getByText('Edit')).toBeInTheDocument();
|
|
252
|
+
|
|
253
|
+
// The node name should be loaded onto the page
|
|
254
|
+
expect(screen.getByText('default.revenue_per_order')).toBeInTheDocument();
|
|
255
|
+
|
|
256
|
+
// The node type should be loaded onto the page
|
|
257
|
+
expect(screen.getByText('metric')).toBeInTheDocument();
|
|
258
|
+
|
|
259
|
+
// The description should be populated
|
|
260
|
+
expect(
|
|
261
|
+
screen.getByText('Average revenue per order (derived metric)'),
|
|
262
|
+
).toBeInTheDocument();
|
|
263
|
+
|
|
264
|
+
// For derived metrics, the upstream node select should show the placeholder
|
|
265
|
+
// (indicating no upstream node is selected - derived metrics have metric parents)
|
|
266
|
+
expect(
|
|
267
|
+
screen.getByText('Select Upstream Node (optional for derived metrics)'),
|
|
268
|
+
).toBeInTheDocument();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('Create metric page renders correctly', async () => {
|
|
273
|
+
const mockDjClient = initializeMockDJClient();
|
|
274
|
+
const element = testElement(mockDjClient);
|
|
275
|
+
renderCreateMetric(element);
|
|
276
|
+
|
|
277
|
+
await waitFor(() => {
|
|
278
|
+
// Should be a create metric page
|
|
279
|
+
expect(screen.getByText('Create')).toBeInTheDocument();
|
|
280
|
+
|
|
281
|
+
// The metric form should show the derived metric expression label
|
|
282
|
+
// (when no upstream is selected, we're in derived metric mode)
|
|
283
|
+
expect(
|
|
284
|
+
screen.getByText('Derived Metric Expression *'),
|
|
285
|
+
).toBeInTheDocument();
|
|
286
|
+
|
|
287
|
+
// The help text for derived metrics should be visible
|
|
288
|
+
expect(
|
|
289
|
+
screen.getByText(/Reference other metrics using their full names/),
|
|
290
|
+
).toBeInTheDocument();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('Metric page handles error loading metrics gracefully', async () => {
|
|
295
|
+
const mockDjClient = initializeMockDJClient();
|
|
296
|
+
// Make metrics() throw an error
|
|
297
|
+
mockDjClient.DataJunctionAPI.metrics.mockRejectedValue(
|
|
298
|
+
new Error('Network error'),
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const consoleSpy = jest
|
|
302
|
+
.spyOn(console, 'error')
|
|
303
|
+
.mockImplementation(() => {});
|
|
304
|
+
|
|
305
|
+
const element = testElement(mockDjClient);
|
|
306
|
+
renderCreateMetric(element);
|
|
307
|
+
|
|
308
|
+
await waitFor(() => {
|
|
309
|
+
// The page should still render despite the error
|
|
310
|
+
expect(screen.getByText('Create')).toBeInTheDocument();
|
|
311
|
+
// The error should be logged
|
|
312
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
313
|
+
'Failed to load metrics for autocomplete:',
|
|
314
|
+
expect.any(Error),
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
consoleSpy.mockRestore();
|
|
319
|
+
});
|
|
224
320
|
});
|
|
@@ -120,6 +120,19 @@ export function AddEditNodePage({ extensions = {} }) {
|
|
|
120
120
|
}
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Build the metric query based on whether an upstream node is provided.
|
|
125
|
+
* - With upstream node: `SELECT <expression> FROM <upstream_node>` (regular metric)
|
|
126
|
+
* - Without upstream node: `SELECT <expression>` (derived metric referencing other metrics)
|
|
127
|
+
*/
|
|
128
|
+
const buildMetricQuery = (aggregateExpression, upstreamNode) => {
|
|
129
|
+
if (upstreamNode && upstreamNode.trim() !== '') {
|
|
130
|
+
return `SELECT ${aggregateExpression} \n FROM ${upstreamNode}`;
|
|
131
|
+
}
|
|
132
|
+
// Derived metric - no FROM clause needed, expression references other metrics directly
|
|
133
|
+
return `SELECT ${aggregateExpression}`;
|
|
134
|
+
};
|
|
135
|
+
|
|
123
136
|
const createNode = async (values, setStatus) => {
|
|
124
137
|
const { status, json } = await djClient.createNode(
|
|
125
138
|
nodeType,
|
|
@@ -127,7 +140,7 @@ export function AddEditNodePage({ extensions = {} }) {
|
|
|
127
140
|
values.display_name,
|
|
128
141
|
values.description,
|
|
129
142
|
values.type === 'metric'
|
|
130
|
-
?
|
|
143
|
+
? buildMetricQuery(values.aggregate_expression, values.upstream_node)
|
|
131
144
|
: values.query,
|
|
132
145
|
values.mode,
|
|
133
146
|
values.namespace,
|
|
@@ -162,7 +175,7 @@ export function AddEditNodePage({ extensions = {} }) {
|
|
|
162
175
|
values.display_name,
|
|
163
176
|
values.description,
|
|
164
177
|
values.type === 'metric'
|
|
165
|
-
?
|
|
178
|
+
? buildMetricQuery(values.aggregate_expression, values.upstream_node)
|
|
166
179
|
: values.query,
|
|
167
180
|
values.mode,
|
|
168
181
|
values.primary_key ? primaryKeyToList(values.primary_key) : null,
|
|
@@ -224,17 +237,46 @@ export function AddEditNodePage({ extensions = {} }) {
|
|
|
224
237
|
};
|
|
225
238
|
|
|
226
239
|
if (node.type === 'METRIC') {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
240
|
+
// Check if this is a derived metric (parent is another metric)
|
|
241
|
+
const firstParent = node.current.parents[0];
|
|
242
|
+
const isDerivedMetric = firstParent?.type === 'METRIC';
|
|
243
|
+
|
|
244
|
+
if (isDerivedMetric) {
|
|
245
|
+
// Derived metric: no upstream node, expression is the full query projection
|
|
246
|
+
// Parse the expression from the query (format: "SELECT <expression>")
|
|
247
|
+
const query = node.current.query || '';
|
|
248
|
+
const selectMatch = query.match(/SELECT\s+(.+)/is);
|
|
249
|
+
const derivedExpression = selectMatch
|
|
250
|
+
? selectMatch[1].trim()
|
|
251
|
+
: node.current.metricMetadata?.expression || '';
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
...baseData,
|
|
255
|
+
metric_direction:
|
|
256
|
+
node.current.metricMetadata?.direction?.toLowerCase(),
|
|
257
|
+
metric_unit: node.current.metricMetadata?.unit?.name?.toLowerCase(),
|
|
258
|
+
significant_digits: node.current.metricMetadata?.significantDigits,
|
|
259
|
+
required_dimensions: node.current.requiredDimensions.map(
|
|
260
|
+
dim => dim.name,
|
|
261
|
+
),
|
|
262
|
+
upstream_node: '', // Derived metrics have no upstream node
|
|
263
|
+
aggregate_expression: derivedExpression,
|
|
264
|
+
};
|
|
265
|
+
} else {
|
|
266
|
+
// Regular metric: has upstream node
|
|
267
|
+
return {
|
|
268
|
+
...baseData,
|
|
269
|
+
metric_direction:
|
|
270
|
+
node.current.metricMetadata?.direction?.toLowerCase(),
|
|
271
|
+
metric_unit: node.current.metricMetadata?.unit?.name?.toLowerCase(),
|
|
272
|
+
significant_digits: node.current.metricMetadata?.significantDigits,
|
|
273
|
+
required_dimensions: node.current.requiredDimensions.map(
|
|
274
|
+
dim => dim.name,
|
|
275
|
+
),
|
|
276
|
+
upstream_node: firstParent?.name || '',
|
|
277
|
+
aggregate_expression: node.current.metricMetadata?.expression,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
238
280
|
}
|
|
239
281
|
return baseData;
|
|
240
282
|
};
|
|
@@ -329,12 +371,14 @@ export function AddEditNodePage({ extensions = {} }) {
|
|
|
329
371
|
/>,
|
|
330
372
|
);
|
|
331
373
|
}
|
|
374
|
+
// For derived metrics, upstream_node is empty - pass null to clear the select
|
|
332
375
|
setSelectUpstreamNode(
|
|
333
376
|
<UpstreamNodeField
|
|
334
|
-
defaultValue={
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
377
|
+
defaultValue={
|
|
378
|
+
data.upstream_node
|
|
379
|
+
? { value: data.upstream_node, label: data.upstream_node }
|
|
380
|
+
: null
|
|
381
|
+
}
|
|
338
382
|
/>,
|
|
339
383
|
);
|
|
340
384
|
if (data.owners) {
|