datajunction-ui 0.0.26 → 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 +3 -3
  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 -6
  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
@@ -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) {
@@ -7,10 +7,7 @@ import CollapsedIcon from '../../icons/CollapsedIcon';
7
7
  const EVENT_TYPES = ['delete', 'update'];
8
8
 
9
9
  export default function WatchButton({ node }) {
10
- if (!node || !node.name || !node.type) {
11
- return null;
12
- }
13
-
10
+ // All hooks must be called before any early returns
14
11
  const djClient = useContext(DJClientContext).DataJunctionAPI;
15
12
  const [selectedEvents, setSelectedEvents] = useState([]);
16
13
  const [loading, setLoading] = useState(false);
@@ -19,6 +16,10 @@ export default function WatchButton({ node }) {
19
16
 
20
17
  // Load existing preferences
21
18
  useEffect(() => {
19
+ if (!node || !node.name || !node.type) {
20
+ return;
21
+ }
22
+
22
23
  const loadPreferences = async () => {
23
24
  try {
24
25
  const preferences = await djClient.getNotificationPreferences({
@@ -38,7 +39,7 @@ export default function WatchButton({ node }) {
38
39
  };
39
40
 
40
41
  loadPreferences();
41
- }, [djClient, node.name, node.type]);
42
+ }, [djClient, node?.name, node?.type]);
42
43
 
43
44
  // Close dropdown on outside click
44
45
  useEffect(() => {
@@ -51,6 +52,12 @@ export default function WatchButton({ node }) {
51
52
  document.addEventListener('mousedown', handleClickOutside);
52
53
  return () => document.removeEventListener('mousedown', handleClickOutside);
53
54
  }, []);
55
+
56
+ // Early return after all hooks are called
57
+ if (!node || !node.name || !node.type) {
58
+ return null;
59
+ }
60
+
54
61
  const toggleEvent = async event => {
55
62
  const isSelected = selectedEvents.includes(event);
56
63
 
@@ -60,8 +60,32 @@ function MetricNode({ data, selected }) {
60
60
  );
61
61
  }
62
62
 
63
+ /**
64
+ * Component node - shows metric building blocks (e.g., SUM, COUNT)
65
+ */
66
+ function ComponentNode({ data, selected }) {
67
+ return (
68
+ <div
69
+ className={`compact-node compact-node-component ${
70
+ selected ? 'selected' : ''
71
+ }`}
72
+ >
73
+ <Handle type="target" position={Position.Left} />
74
+ <div className="compact-node-icon">●</div>
75
+ <div className="compact-node-content">
76
+ <div className="compact-node-name">{data.shortName}</div>
77
+ <div className="compact-node-meta">
78
+ <span className="meta-item">{data.aggregation || 'RAW'}</span>
79
+ </div>
80
+ </div>
81
+ <Handle type="source" position={Position.Right} />
82
+ </div>
83
+ );
84
+ }
85
+
63
86
  const nodeTypes = {
64
87
  preagg: PreAggNode,
88
+ component: ComponentNode,
65
89
  metric: MetricNode,
66
90
  };
67
91
 
@@ -132,6 +156,7 @@ export function MetricFlowGraph({
132
156
 
133
157
  // Track mappings
134
158
  const preAggNodesMap = new Map();
159
+ const componentNodeIds = new Map();
135
160
  const componentToPreAgg = new Map();
136
161
 
137
162
  let nodeId = 0;
@@ -167,6 +192,37 @@ export function MetricFlowGraph({
167
192
  });
168
193
  });
169
194
 
195
+ // Create component nodes (building blocks for metrics)
196
+ grainGroups.forEach((gg, ggIdx) => {
197
+ gg.components?.forEach(comp => {
198
+ if (!componentNodeIds.has(comp.name)) {
199
+ const id = getNextId();
200
+ componentNodeIds.set(comp.name, id);
201
+ // Shorten name for display (e.g., "unit_price_sum" -> "price_sum")
202
+ const shortName =
203
+ comp.name.length > 40
204
+ ? '...' + comp.name.split('_').slice(-2).join('_')
205
+ : comp.name;
206
+
207
+ rawNodes.push({
208
+ id,
209
+ type: 'component',
210
+ position: { x: 0, y: 0 },
211
+ data: {
212
+ name: comp.name,
213
+ shortName,
214
+ aggregation: comp.aggregation,
215
+ merge: comp.merge,
216
+ grainGroupIndex: ggIdx,
217
+ },
218
+ selected:
219
+ selectedNode?.type === 'component' &&
220
+ selectedNode?.name === comp.name,
221
+ });
222
+ }
223
+ });
224
+ });
225
+
170
226
  // Create metric nodes
171
227
  const metricNodeIds = new Map();
172
228
 
@@ -191,26 +247,38 @@ export function MetricFlowGraph({
191
247
  });
192
248
  });
193
249
 
194
- // Create edges
195
- metricFormulas.forEach(metric => {
196
- const metricId = metricNodeIds.get(metric.name);
197
- const connectedPreAggs = new Set();
198
-
199
- metric.components?.forEach(compName => {
200
- const preAggIdx = componentToPreAgg.get(compName);
201
- if (preAggIdx !== undefined) {
202
- connectedPreAggs.add(preAggIdx);
250
+ // Create edges: PreAgg -> Component
251
+ grainGroups.forEach((gg, ggIdx) => {
252
+ const preAggId = preAggNodesMap.get(ggIdx);
253
+ gg.components?.forEach(comp => {
254
+ const compId = componentNodeIds.get(comp.name);
255
+ if (preAggId && compId) {
256
+ rawEdges.push({
257
+ id: `edge-preagg-${preAggId}-${compId}`,
258
+ source: preAggId,
259
+ target: compId,
260
+ style: { stroke: '#64748b', strokeWidth: 2 },
261
+ markerEnd: {
262
+ type: MarkerType.ArrowClosed,
263
+ color: '#64748b',
264
+ width: 16,
265
+ height: 16,
266
+ },
267
+ });
203
268
  }
204
269
  });
270
+ });
205
271
 
206
- connectedPreAggs.forEach(preAggIdx => {
207
- const preAggId = preAggNodesMap.get(preAggIdx);
208
- if (preAggId && metricId) {
272
+ // Create edges: Component -> Metric
273
+ metricFormulas.forEach(metric => {
274
+ const metricId = metricNodeIds.get(metric.name);
275
+ metric.components?.forEach(compName => {
276
+ const compId = componentNodeIds.get(compName);
277
+ if (compId && metricId) {
209
278
  rawEdges.push({
210
- id: `edge-${preAggId}-${metricId}`,
211
- source: preAggId,
279
+ id: `edge-comp-${compId}-${metricId}`,
280
+ source: compId,
212
281
  target: metricId,
213
- type: 'default', // Straight/bezier edges
214
282
  style: { stroke: '#64748b', strokeWidth: 2 },
215
283
  markerEnd: {
216
284
  type: MarkerType.ArrowClosed,
@@ -244,6 +312,12 @@ export function MetricFlowGraph({
244
312
  index: node.data.grainGroupIndex,
245
313
  data: grainGroups[node.data.grainGroupIndex],
246
314
  });
315
+ } else if (node.type === 'component') {
316
+ onNodeSelect?.({
317
+ type: 'component',
318
+ name: node.data.name,
319
+ data: node.data,
320
+ });
247
321
  } else if (node.type === 'metric') {
248
322
  onNodeSelect?.({
249
323
  type: 'metric',
@@ -295,6 +369,10 @@ export function MetricFlowGraph({
295
369
  <span className="legend-dot preagg"></span>
296
370
  <span>Pre-agg</span>
297
371
  </div>
372
+ <div className="legend-item">
373
+ <span className="legend-dot component"></span>
374
+ <span>Component</span>
375
+ </div>
298
376
  <div className="legend-item">
299
377
  <span className="legend-dot metric"></span>
300
378
  <span>Metric</span>