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.
Files changed (28) hide show
  1. package/package.json +2 -2
  2. package/src/app/components/Search.jsx +41 -33
  3. package/src/app/components/__tests__/Search.test.jsx +46 -11
  4. package/src/app/index.tsx +1 -1
  5. package/src/app/pages/AddEditNodePage/MetricQueryField.jsx +57 -8
  6. package/src/app/pages/AddEditNodePage/UpstreamNodeField.jsx +17 -5
  7. package/src/app/pages/AddEditNodePage/__tests__/index.test.jsx +97 -1
  8. package/src/app/pages/AddEditNodePage/index.jsx +61 -17
  9. package/src/app/pages/NodePage/WatchNodeButton.jsx +12 -5
  10. package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +93 -15
  11. package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +2320 -65
  12. package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +234 -25
  13. package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +315 -122
  14. package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +2672 -314
  15. package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +567 -0
  16. package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +480 -55
  17. package/src/app/pages/QueryPlannerPage/index.jsx +1021 -14
  18. package/src/app/pages/QueryPlannerPage/styles.css +1990 -62
  19. package/src/app/pages/Root/__tests__/index.test.jsx +79 -8
  20. package/src/app/pages/Root/index.tsx +1 -1
  21. package/src/app/pages/SQLBuilderPage/__tests__/index.test.jsx +82 -0
  22. package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +37 -0
  23. package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +48 -0
  24. package/src/app/pages/SettingsPage/__tests__/index.test.jsx +169 -1
  25. package/src/app/services/DJService.js +492 -3
  26. package/src/app/services/__tests__/DJService.test.jsx +582 -0
  27. package/src/mocks/mockNodes.jsx +36 -0
  28. 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.26-alpha.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": 70,
170
+ "branches": 69,
171
171
  "lines": 80,
172
172
  "functions": 80
173
173
  }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useContext } from 'react';
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
- useEffect(() => {
22
- const fetchNodes = async () => {
23
- try {
24
- const [data, tags] = await Promise.all([
25
- djClient.nodeDetails(),
26
- djClient.listTags(),
27
- ]);
28
- const allEntities = data.concat(
29
- (tags || []).map(tag => {
30
- tag.type = 'tag';
31
- return tag;
32
- }),
33
- );
34
- const fuse = new Fuse(allEntities || [], {
35
- keys: [
36
- 'name', // will be assigned a `weight` of 1
37
- { name: 'description', weight: 2 },
38
- { name: 'display_name', weight: 3 },
39
- { name: 'type', weight: 4 },
40
- { name: 'tag_type', weight: 5 },
41
- ],
42
- });
43
- setFuse(fuse);
44
- } catch (error) {
45
- console.error('Error fetching nodes or tags:', error);
46
- }
47
- };
48
- fetchNodes();
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="Search"
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 key={item.name} className="search-result-item">
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 mount', async () => {
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
- // Based on the selected upstream, we load the upstream node's columns
17
- // into the autocomplete schema
34
+ const newSchema = {};
18
35
  const nodeName = formik.values['upstream_node'];
19
- const nodeDetails = await djClient.node(nodeName);
20
- nodeDetails.columns.forEach(col => {
21
- schema[col.name] = [];
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
- setSchema(schema);
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">Aggregate Expression *</label>
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="mode" component="span" />
36
- <label htmlFor="Mode">Upstream Node *</label>
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
- ? `SELECT ${values.aggregate_expression} \n FROM ${values.upstream_node}`
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
- ? `SELECT ${values.aggregate_expression} \n FROM ${values.upstream_node}`
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
- return {
228
- ...baseData,
229
- metric_direction: node.current.metricMetadata?.direction?.toLowerCase(),
230
- metric_unit: node.current.metricMetadata?.unit?.name?.toLowerCase(),
231
- significant_digits: node.current.metricMetadata?.significantDigits,
232
- required_dimensions: node.current.requiredDimensions.map(
233
- dim => dim.name,
234
- ),
235
- upstream_node: node.current.parents[0]?.name,
236
- aggregate_expression: node.current.metricMetadata?.expression,
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
- value: data.upstream_node,
336
- label: data.upstream_node,
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) {