datajunction-ui 0.0.23 → 0.0.26
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/index.tsx +6 -0
- package/src/app/pages/NamespacePage/CompactSelect.jsx +100 -0
- package/src/app/pages/NamespacePage/NodeModeSelect.jsx +8 -5
- package/src/app/pages/NamespacePage/__tests__/CompactSelect.test.jsx +190 -0
- package/src/app/pages/NamespacePage/__tests__/index.test.jsx +297 -8
- package/src/app/pages/NamespacePage/index.jsx +489 -62
- package/src/app/pages/QueryPlannerPage/Loadable.jsx +6 -0
- package/src/app/pages/QueryPlannerPage/MetricFlowGraph.jsx +311 -0
- package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +470 -0
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +384 -0
- package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +239 -0
- package/src/app/pages/QueryPlannerPage/__tests__/PreAggDetailsPanel.test.jsx +638 -0
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +429 -0
- package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +317 -0
- package/src/app/pages/QueryPlannerPage/index.jsx +209 -0
- package/src/app/pages/QueryPlannerPage/styles.css +1251 -0
- package/src/app/pages/Root/index.tsx +5 -0
- package/src/app/services/DJService.js +61 -2
- package/src/styles/index.css +2 -2
- package/src/app/icons/FilterIcon.jsx +0 -7
- package/src/app/pages/NamespacePage/FieldControl.jsx +0 -21
- package/src/app/pages/NamespacePage/NodeTypeSelect.jsx +0 -30
- package/src/app/pages/NamespacePage/TagSelect.jsx +0 -44
- package/src/app/pages/NamespacePage/UserSelect.jsx +0 -47
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { useState, useMemo, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SelectionPanel - Browse and select metrics and dimensions
|
|
5
|
+
*/
|
|
6
|
+
export function SelectionPanel({
|
|
7
|
+
metrics,
|
|
8
|
+
selectedMetrics,
|
|
9
|
+
onMetricsChange,
|
|
10
|
+
dimensions,
|
|
11
|
+
selectedDimensions,
|
|
12
|
+
onDimensionsChange,
|
|
13
|
+
loading,
|
|
14
|
+
}) {
|
|
15
|
+
const [metricsSearch, setMetricsSearch] = useState('');
|
|
16
|
+
const [dimensionsSearch, setDimensionsSearch] = useState('');
|
|
17
|
+
const [expandedNamespaces, setExpandedNamespaces] = useState(new Set());
|
|
18
|
+
const prevSearchRef = useRef('');
|
|
19
|
+
|
|
20
|
+
// Get short name from full metric name
|
|
21
|
+
const getShortName = fullName => {
|
|
22
|
+
const parts = fullName.split('.');
|
|
23
|
+
return parts[parts.length - 1];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Get namespace from full metric name
|
|
27
|
+
const getNamespace = fullName => {
|
|
28
|
+
const parts = fullName.split('.');
|
|
29
|
+
return parts.length > 1 ? parts.slice(0, -1).join('.') : 'default';
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Group metrics by namespace (e.g., "default.cube" -> "default")
|
|
33
|
+
const groupedMetrics = useMemo(() => {
|
|
34
|
+
const groups = {};
|
|
35
|
+
metrics.forEach(metric => {
|
|
36
|
+
const namespace = getNamespace(metric);
|
|
37
|
+
if (!groups[namespace]) {
|
|
38
|
+
groups[namespace] = [];
|
|
39
|
+
}
|
|
40
|
+
groups[namespace].push(metric);
|
|
41
|
+
});
|
|
42
|
+
return groups;
|
|
43
|
+
}, [metrics]);
|
|
44
|
+
|
|
45
|
+
// Filter and sort namespaces/metrics by search relevance
|
|
46
|
+
// Namespaces matching the search term appear first, then sorted by metric matches
|
|
47
|
+
const { filteredGroups, sortedNamespaces } = useMemo(() => {
|
|
48
|
+
const search = metricsSearch.trim().toLowerCase();
|
|
49
|
+
|
|
50
|
+
if (!search) {
|
|
51
|
+
// No search - return original groups, sorted alphabetically
|
|
52
|
+
const namespaces = Object.keys(groupedMetrics).sort();
|
|
53
|
+
return { filteredGroups: groupedMetrics, sortedNamespaces: namespaces };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Filter to groups that have matching metrics
|
|
57
|
+
const filtered = {};
|
|
58
|
+
Object.entries(groupedMetrics).forEach(([namespace, items]) => {
|
|
59
|
+
const matchingItems = items.filter(m => m.toLowerCase().includes(search));
|
|
60
|
+
if (matchingItems.length > 0) {
|
|
61
|
+
// Sort metrics within namespace: prefix matches first
|
|
62
|
+
matchingItems.sort((a, b) => {
|
|
63
|
+
const aShort = getShortName(a).toLowerCase();
|
|
64
|
+
const bShort = getShortName(b).toLowerCase();
|
|
65
|
+
|
|
66
|
+
const aPrefix = aShort.startsWith(search);
|
|
67
|
+
const bPrefix = bShort.startsWith(search);
|
|
68
|
+
if (aPrefix && !bPrefix) return -1;
|
|
69
|
+
if (!aPrefix && bPrefix) return 1;
|
|
70
|
+
|
|
71
|
+
return aShort.localeCompare(bShort);
|
|
72
|
+
});
|
|
73
|
+
filtered[namespace] = matchingItems;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Sort namespaces by relevance
|
|
78
|
+
const namespaces = Object.keys(filtered).sort((a, b) => {
|
|
79
|
+
const aLower = a.toLowerCase();
|
|
80
|
+
const bLower = b.toLowerCase();
|
|
81
|
+
|
|
82
|
+
// Priority 1: Namespace starts with search term
|
|
83
|
+
const aPrefix = aLower.startsWith(search);
|
|
84
|
+
const bPrefix = bLower.startsWith(search);
|
|
85
|
+
if (aPrefix && !bPrefix) return -1;
|
|
86
|
+
if (!aPrefix && bPrefix) return 1;
|
|
87
|
+
|
|
88
|
+
// Priority 2: Namespace contains search term
|
|
89
|
+
const aContains = aLower.includes(search);
|
|
90
|
+
const bContains = bLower.includes(search);
|
|
91
|
+
if (aContains && !bContains) return -1;
|
|
92
|
+
if (!aContains && bContains) return 1;
|
|
93
|
+
|
|
94
|
+
// Priority 3: Has more matching metrics
|
|
95
|
+
const aCount = filtered[a].length;
|
|
96
|
+
const bCount = filtered[b].length;
|
|
97
|
+
if (aCount !== bCount) return bCount - aCount;
|
|
98
|
+
|
|
99
|
+
// Priority 4: Alphabetical
|
|
100
|
+
return aLower.localeCompare(bLower);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return { filteredGroups: filtered, sortedNamespaces: namespaces };
|
|
104
|
+
}, [groupedMetrics, metricsSearch]);
|
|
105
|
+
|
|
106
|
+
// Auto-expand all matching namespaces when search changes
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const currentSearch = metricsSearch.trim();
|
|
109
|
+
const prevSearch = prevSearchRef.current;
|
|
110
|
+
|
|
111
|
+
// Only auto-expand when starting a new search or search term changes
|
|
112
|
+
if (currentSearch && currentSearch !== prevSearch) {
|
|
113
|
+
setExpandedNamespaces(new Set(sortedNamespaces));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
prevSearchRef.current = currentSearch;
|
|
117
|
+
}, [metricsSearch, sortedNamespaces]);
|
|
118
|
+
|
|
119
|
+
// Dedupe dimensions by name, keeping shortest path for each
|
|
120
|
+
const dedupedDimensions = useMemo(() => {
|
|
121
|
+
const byName = new Map();
|
|
122
|
+
dimensions.forEach(d => {
|
|
123
|
+
if (!d.name) return;
|
|
124
|
+
const existing = byName.get(d.name);
|
|
125
|
+
if (
|
|
126
|
+
!existing ||
|
|
127
|
+
(d.path?.length || 0) < (existing.path?.length || Infinity)
|
|
128
|
+
) {
|
|
129
|
+
byName.set(d.name, d);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return Array.from(byName.values());
|
|
133
|
+
}, [dimensions]);
|
|
134
|
+
|
|
135
|
+
// Filter and sort dimensions by search (prefix matches first)
|
|
136
|
+
const filteredDimensions = useMemo(() => {
|
|
137
|
+
const search = dimensionsSearch.trim().toLowerCase();
|
|
138
|
+
if (!search) return dedupedDimensions;
|
|
139
|
+
|
|
140
|
+
// Search in both full name and short display name
|
|
141
|
+
const matches = dedupedDimensions.filter(d => {
|
|
142
|
+
if (!d.name) return false;
|
|
143
|
+
const fullName = d.name.toLowerCase();
|
|
144
|
+
const parts = d.name.split('.');
|
|
145
|
+
const shortDisplay = parts.slice(-2).join('.').toLowerCase();
|
|
146
|
+
return fullName.includes(search) || shortDisplay.includes(search);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Sort: prefix matches on short name first
|
|
150
|
+
matches.sort((a, b) => {
|
|
151
|
+
const aParts = (a.name || '').split('.');
|
|
152
|
+
const bParts = (b.name || '').split('.');
|
|
153
|
+
const aShort = aParts.slice(-2).join('.').toLowerCase();
|
|
154
|
+
const bShort = bParts.slice(-2).join('.').toLowerCase();
|
|
155
|
+
|
|
156
|
+
const aPrefix = aShort.startsWith(search);
|
|
157
|
+
const bPrefix = bShort.startsWith(search);
|
|
158
|
+
if (aPrefix && !bPrefix) return -1;
|
|
159
|
+
if (!aPrefix && bPrefix) return 1;
|
|
160
|
+
|
|
161
|
+
return aShort.localeCompare(bShort);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
return matches;
|
|
165
|
+
}, [dedupedDimensions, dimensionsSearch]);
|
|
166
|
+
|
|
167
|
+
// Get display name for dimension (last 2 segments: dim_node.column)
|
|
168
|
+
const getDimDisplayName = fullName => {
|
|
169
|
+
const parts = (fullName || '').split('.');
|
|
170
|
+
return parts.slice(-2).join('.');
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const toggleNamespace = namespace => {
|
|
174
|
+
setExpandedNamespaces(prev => {
|
|
175
|
+
const next = new Set(prev);
|
|
176
|
+
if (next.has(namespace)) {
|
|
177
|
+
next.delete(namespace);
|
|
178
|
+
} else {
|
|
179
|
+
next.add(namespace);
|
|
180
|
+
}
|
|
181
|
+
return next;
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const toggleMetric = metric => {
|
|
186
|
+
if (selectedMetrics.includes(metric)) {
|
|
187
|
+
onMetricsChange(selectedMetrics.filter(m => m !== metric));
|
|
188
|
+
} else {
|
|
189
|
+
onMetricsChange([...selectedMetrics, metric]);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const toggleDimension = dimName => {
|
|
194
|
+
if (selectedDimensions.includes(dimName)) {
|
|
195
|
+
onDimensionsChange(selectedDimensions.filter(d => d !== dimName));
|
|
196
|
+
} else {
|
|
197
|
+
onDimensionsChange([...selectedDimensions, dimName]);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const selectAllInNamespace = (namespace, items) => {
|
|
202
|
+
const newSelection = [...new Set([...selectedMetrics, ...items])];
|
|
203
|
+
onMetricsChange(newSelection);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const deselectAllInNamespace = (namespace, items) => {
|
|
207
|
+
onMetricsChange(selectedMetrics.filter(m => !items.includes(m)));
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div className="selection-panel">
|
|
212
|
+
{/* Metrics Section */}
|
|
213
|
+
<div className="selection-section">
|
|
214
|
+
<div className="section-header">
|
|
215
|
+
<h3>Metrics</h3>
|
|
216
|
+
<span className="selection-count">
|
|
217
|
+
{selectedMetrics.length} selected
|
|
218
|
+
</span>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div className="search-box">
|
|
222
|
+
<input
|
|
223
|
+
type="text"
|
|
224
|
+
placeholder="Search metrics..."
|
|
225
|
+
value={metricsSearch}
|
|
226
|
+
onChange={e => setMetricsSearch(e.target.value)}
|
|
227
|
+
/>
|
|
228
|
+
{metricsSearch && (
|
|
229
|
+
<button
|
|
230
|
+
className="clear-search"
|
|
231
|
+
onClick={() => setMetricsSearch('')}
|
|
232
|
+
>
|
|
233
|
+
×
|
|
234
|
+
</button>
|
|
235
|
+
)}
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div className="selection-list">
|
|
239
|
+
{sortedNamespaces.map(namespace => {
|
|
240
|
+
const items = filteredGroups[namespace];
|
|
241
|
+
const isExpanded = expandedNamespaces.has(namespace);
|
|
242
|
+
const selectedInNamespace = items.filter(m =>
|
|
243
|
+
selectedMetrics.includes(m),
|
|
244
|
+
).length;
|
|
245
|
+
|
|
246
|
+
return (
|
|
247
|
+
<div key={namespace} className="namespace-group">
|
|
248
|
+
<div
|
|
249
|
+
className="namespace-header"
|
|
250
|
+
onClick={() => toggleNamespace(namespace)}
|
|
251
|
+
>
|
|
252
|
+
<span className="expand-icon">{isExpanded ? '▼' : '▶'}</span>
|
|
253
|
+
<span className="namespace-name">{namespace}</span>
|
|
254
|
+
<span className="namespace-count">
|
|
255
|
+
{selectedInNamespace > 0 && (
|
|
256
|
+
<span className="selected-badge">
|
|
257
|
+
{selectedInNamespace}
|
|
258
|
+
</span>
|
|
259
|
+
)}
|
|
260
|
+
{items.length}
|
|
261
|
+
</span>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
{isExpanded && (
|
|
265
|
+
<div className="namespace-items">
|
|
266
|
+
<div className="namespace-actions">
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
className="select-all-btn"
|
|
270
|
+
onClick={() => selectAllInNamespace(namespace, items)}
|
|
271
|
+
>
|
|
272
|
+
Select all
|
|
273
|
+
</button>
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
className="select-all-btn"
|
|
277
|
+
onClick={() => deselectAllInNamespace(namespace, items)}
|
|
278
|
+
>
|
|
279
|
+
Clear
|
|
280
|
+
</button>
|
|
281
|
+
</div>
|
|
282
|
+
{items.map(metric => (
|
|
283
|
+
<label key={metric} className="selection-item">
|
|
284
|
+
<input
|
|
285
|
+
type="checkbox"
|
|
286
|
+
checked={selectedMetrics.includes(metric)}
|
|
287
|
+
onChange={() => toggleMetric(metric)}
|
|
288
|
+
/>
|
|
289
|
+
<span className="item-name">
|
|
290
|
+
{getShortName(metric)}
|
|
291
|
+
</span>
|
|
292
|
+
</label>
|
|
293
|
+
))}
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
})}
|
|
299
|
+
|
|
300
|
+
{sortedNamespaces.length === 0 && (
|
|
301
|
+
<div className="empty-list">
|
|
302
|
+
{metricsSearch
|
|
303
|
+
? 'No metrics match your search'
|
|
304
|
+
: 'No metrics available'}
|
|
305
|
+
</div>
|
|
306
|
+
)}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
{/* Divider */}
|
|
311
|
+
<div className="section-divider" />
|
|
312
|
+
|
|
313
|
+
{/* Dimensions Section */}
|
|
314
|
+
<div className="selection-section">
|
|
315
|
+
<div className="section-header">
|
|
316
|
+
<h3>Dimensions</h3>
|
|
317
|
+
<span className="selection-count">
|
|
318
|
+
{selectedDimensions.length} selected
|
|
319
|
+
{dimensions.length > 0 && ` / ${dimensions.length} available`}
|
|
320
|
+
</span>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
{selectedMetrics.length === 0 ? (
|
|
324
|
+
<div className="empty-list hint">
|
|
325
|
+
Select metrics to see available dimensions
|
|
326
|
+
</div>
|
|
327
|
+
) : loading ? (
|
|
328
|
+
<div className="empty-list">Loading dimensions...</div>
|
|
329
|
+
) : (
|
|
330
|
+
<>
|
|
331
|
+
<div className="search-box">
|
|
332
|
+
<input
|
|
333
|
+
type="text"
|
|
334
|
+
placeholder="Search dimensions..."
|
|
335
|
+
value={dimensionsSearch}
|
|
336
|
+
onChange={e => setDimensionsSearch(e.target.value)}
|
|
337
|
+
/>
|
|
338
|
+
{dimensionsSearch && (
|
|
339
|
+
<button
|
|
340
|
+
className="clear-search"
|
|
341
|
+
onClick={() => setDimensionsSearch('')}
|
|
342
|
+
>
|
|
343
|
+
×
|
|
344
|
+
</button>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div className="selection-list dimensions-list">
|
|
349
|
+
{filteredDimensions.map(dim => (
|
|
350
|
+
<label key={dim.name} className="selection-item dimension-item">
|
|
351
|
+
<input
|
|
352
|
+
type="checkbox"
|
|
353
|
+
checked={selectedDimensions.includes(dim.name)}
|
|
354
|
+
onChange={() => toggleDimension(dim.name)}
|
|
355
|
+
/>
|
|
356
|
+
<div className="dimension-info">
|
|
357
|
+
<span className="item-name">
|
|
358
|
+
{getDimDisplayName(dim.name)}
|
|
359
|
+
</span>
|
|
360
|
+
{dim.path && dim.path.length > 1 && (
|
|
361
|
+
<span className="dimension-path">
|
|
362
|
+
{dim.path.slice(1).join(' ▶ ')}
|
|
363
|
+
</span>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
</label>
|
|
367
|
+
))}
|
|
368
|
+
|
|
369
|
+
{filteredDimensions.length === 0 && (
|
|
370
|
+
<div className="empty-list">
|
|
371
|
+
{dimensionsSearch
|
|
372
|
+
? 'No dimensions match your search'
|
|
373
|
+
: 'No shared dimensions'}
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
</div>
|
|
377
|
+
</>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export default SelectionPanel;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
|
|
4
|
+
// Mock the entire MetricFlowGraph module since dagre is difficult to mock
|
|
5
|
+
jest.mock('../MetricFlowGraph', () => ({
|
|
6
|
+
MetricFlowGraph: ({
|
|
7
|
+
grainGroups,
|
|
8
|
+
metricFormulas,
|
|
9
|
+
selectedNode,
|
|
10
|
+
onNodeSelect,
|
|
11
|
+
}) => {
|
|
12
|
+
if (!grainGroups?.length || !metricFormulas?.length) {
|
|
13
|
+
return (
|
|
14
|
+
<div data-testid="graph-empty">
|
|
15
|
+
Select metrics and dimensions above to visualize the data flow
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return (
|
|
20
|
+
<div data-testid="metric-flow-graph">
|
|
21
|
+
<div data-testid="nodes-count">
|
|
22
|
+
{grainGroups.length + metricFormulas.length}
|
|
23
|
+
</div>
|
|
24
|
+
{grainGroups.map((gg, i) => (
|
|
25
|
+
<div
|
|
26
|
+
key={`preagg-${i}`}
|
|
27
|
+
data-testid={`preagg-node-${i}`}
|
|
28
|
+
onClick={() =>
|
|
29
|
+
onNodeSelect?.({ type: 'preagg', index: i, data: gg })
|
|
30
|
+
}
|
|
31
|
+
>
|
|
32
|
+
{gg.parent_name?.split('.').pop()}
|
|
33
|
+
</div>
|
|
34
|
+
))}
|
|
35
|
+
{metricFormulas.map((m, i) => (
|
|
36
|
+
<div
|
|
37
|
+
key={`metric-${i}`}
|
|
38
|
+
data-testid={`metric-node-${i}`}
|
|
39
|
+
onClick={() =>
|
|
40
|
+
onNodeSelect?.({ type: 'metric', index: i, data: m })
|
|
41
|
+
}
|
|
42
|
+
>
|
|
43
|
+
{m.short_name}
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
<div data-testid="legend">
|
|
47
|
+
<span>Pre-agg</span>
|
|
48
|
+
<span>Metric</span>
|
|
49
|
+
<span>Derived</span>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
// Import after mock
|
|
57
|
+
const { MetricFlowGraph } = require('../MetricFlowGraph');
|
|
58
|
+
|
|
59
|
+
const mockGrainGroups = [
|
|
60
|
+
{
|
|
61
|
+
parent_name: 'default.repair_orders',
|
|
62
|
+
aggregability: 'FULL',
|
|
63
|
+
grain: ['date_id', 'customer_id'],
|
|
64
|
+
components: [
|
|
65
|
+
{ name: 'sum_revenue', expression: 'SUM(revenue)' },
|
|
66
|
+
{ name: 'count_orders', expression: 'COUNT(*)' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
parent_name: 'inventory.stock',
|
|
71
|
+
aggregability: 'LIMITED',
|
|
72
|
+
grain: ['warehouse_id'],
|
|
73
|
+
components: [{ name: 'sum_quantity', expression: 'SUM(quantity)' }],
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const mockMetricFormulas = [
|
|
78
|
+
{
|
|
79
|
+
name: 'default.total_revenue',
|
|
80
|
+
short_name: 'total_revenue',
|
|
81
|
+
combiner: 'SUM(sum_revenue)',
|
|
82
|
+
is_derived: false,
|
|
83
|
+
components: ['sum_revenue'],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'default.order_count',
|
|
87
|
+
short_name: 'order_count',
|
|
88
|
+
combiner: 'SUM(count_orders)',
|
|
89
|
+
is_derived: false,
|
|
90
|
+
components: ['count_orders'],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'default.avg_order_value',
|
|
94
|
+
short_name: 'avg_order_value',
|
|
95
|
+
combiner: 'SUM(sum_revenue) / SUM(count_orders)',
|
|
96
|
+
is_derived: true,
|
|
97
|
+
components: ['sum_revenue', 'count_orders'],
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
describe('MetricFlowGraph', () => {
|
|
102
|
+
const defaultProps = {
|
|
103
|
+
grainGroups: mockGrainGroups,
|
|
104
|
+
metricFormulas: mockMetricFormulas,
|
|
105
|
+
selectedNode: null,
|
|
106
|
+
onNodeSelect: jest.fn(),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
jest.clearAllMocks();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('Empty State', () => {
|
|
114
|
+
it('shows empty state when no grain groups', () => {
|
|
115
|
+
render(<MetricFlowGraph {...defaultProps} grainGroups={[]} />);
|
|
116
|
+
expect(screen.getByTestId('graph-empty')).toBeInTheDocument();
|
|
117
|
+
expect(
|
|
118
|
+
screen.getByText(
|
|
119
|
+
'Select metrics and dimensions above to visualize the data flow',
|
|
120
|
+
),
|
|
121
|
+
).toBeInTheDocument();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('shows empty state when no metric formulas', () => {
|
|
125
|
+
render(<MetricFlowGraph {...defaultProps} metricFormulas={[]} />);
|
|
126
|
+
expect(screen.getByTestId('graph-empty')).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('shows empty state when both are null', () => {
|
|
130
|
+
render(
|
|
131
|
+
<MetricFlowGraph
|
|
132
|
+
{...defaultProps}
|
|
133
|
+
grainGroups={null}
|
|
134
|
+
metricFormulas={null}
|
|
135
|
+
/>,
|
|
136
|
+
);
|
|
137
|
+
expect(screen.getByTestId('graph-empty')).toBeInTheDocument();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('Graph Rendering', () => {
|
|
142
|
+
it('renders graph container when data is provided', () => {
|
|
143
|
+
render(<MetricFlowGraph {...defaultProps} />);
|
|
144
|
+
expect(screen.getByTestId('metric-flow-graph')).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('renders correct number of nodes', () => {
|
|
148
|
+
render(<MetricFlowGraph {...defaultProps} />);
|
|
149
|
+
// 2 pre-agg nodes + 3 metric nodes = 5 total
|
|
150
|
+
expect(screen.getByTestId('nodes-count')).toHaveTextContent('5');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('displays pre-aggregation short names', () => {
|
|
154
|
+
render(<MetricFlowGraph {...defaultProps} />);
|
|
155
|
+
expect(screen.getByText('repair_orders')).toBeInTheDocument();
|
|
156
|
+
expect(screen.getByText('stock')).toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('displays metric short names', () => {
|
|
160
|
+
render(<MetricFlowGraph {...defaultProps} />);
|
|
161
|
+
expect(screen.getByText('total_revenue')).toBeInTheDocument();
|
|
162
|
+
expect(screen.getByText('order_count')).toBeInTheDocument();
|
|
163
|
+
expect(screen.getByText('avg_order_value')).toBeInTheDocument();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('Node Selection', () => {
|
|
168
|
+
it('calls onNodeSelect when preagg node is clicked', () => {
|
|
169
|
+
const onNodeSelect = jest.fn();
|
|
170
|
+
render(<MetricFlowGraph {...defaultProps} onNodeSelect={onNodeSelect} />);
|
|
171
|
+
|
|
172
|
+
const preaggNode = screen.getByTestId('preagg-node-0');
|
|
173
|
+
preaggNode.click();
|
|
174
|
+
|
|
175
|
+
expect(onNodeSelect).toHaveBeenCalledWith(
|
|
176
|
+
expect.objectContaining({
|
|
177
|
+
type: 'preagg',
|
|
178
|
+
index: 0,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('calls onNodeSelect when metric node is clicked', () => {
|
|
184
|
+
const onNodeSelect = jest.fn();
|
|
185
|
+
render(<MetricFlowGraph {...defaultProps} onNodeSelect={onNodeSelect} />);
|
|
186
|
+
|
|
187
|
+
const metricNode = screen.getByTestId('metric-node-0');
|
|
188
|
+
metricNode.click();
|
|
189
|
+
|
|
190
|
+
expect(onNodeSelect).toHaveBeenCalledWith(
|
|
191
|
+
expect.objectContaining({
|
|
192
|
+
type: 'metric',
|
|
193
|
+
index: 0,
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('passes grain data when preagg is selected', () => {
|
|
199
|
+
const onNodeSelect = jest.fn();
|
|
200
|
+
render(<MetricFlowGraph {...defaultProps} onNodeSelect={onNodeSelect} />);
|
|
201
|
+
|
|
202
|
+
const preaggNode = screen.getByTestId('preagg-node-0');
|
|
203
|
+
preaggNode.click();
|
|
204
|
+
|
|
205
|
+
expect(onNodeSelect).toHaveBeenCalledWith(
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
data: expect.objectContaining({
|
|
208
|
+
grain: ['date_id', 'customer_id'],
|
|
209
|
+
}),
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('passes combiner data when metric is selected', () => {
|
|
215
|
+
const onNodeSelect = jest.fn();
|
|
216
|
+
render(<MetricFlowGraph {...defaultProps} onNodeSelect={onNodeSelect} />);
|
|
217
|
+
|
|
218
|
+
const metricNode = screen.getByTestId('metric-node-0');
|
|
219
|
+
metricNode.click();
|
|
220
|
+
|
|
221
|
+
expect(onNodeSelect).toHaveBeenCalledWith(
|
|
222
|
+
expect.objectContaining({
|
|
223
|
+
data: expect.objectContaining({
|
|
224
|
+
combiner: 'SUM(sum_revenue)',
|
|
225
|
+
}),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('Legend', () => {
|
|
232
|
+
it('renders graph legend', () => {
|
|
233
|
+
render(<MetricFlowGraph {...defaultProps} />);
|
|
234
|
+
expect(screen.getByText('Pre-agg')).toBeInTheDocument();
|
|
235
|
+
expect(screen.getByText('Metric')).toBeInTheDocument();
|
|
236
|
+
expect(screen.getByText('Derived')).toBeInTheDocument();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|