datajunction-ui 0.0.144 → 0.0.146
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 +1 -1
- package/src/app/pages/CubeBuilderPage/CubePreviewPanel.jsx +173 -0
- package/src/app/pages/CubeBuilderPage/DimensionsSelect.jsx +268 -97
- package/src/app/pages/CubeBuilderPage/MetricsSelect.jsx +273 -60
- package/src/app/pages/CubeBuilderPage/__tests__/CubePreviewPanel.test.jsx +108 -0
- package/src/app/pages/CubeBuilderPage/__tests__/DimensionsSelect.test.jsx +229 -0
- package/src/app/pages/CubeBuilderPage/__tests__/MetricsSelect.test.jsx +137 -0
- package/src/app/pages/CubeBuilderPage/__tests__/index.test.jsx +145 -37
- package/src/app/pages/CubeBuilderPage/index.jsx +367 -125
- package/src/app/pages/CubeBuilderPage/styles.css +489 -0
- package/src/app/pages/NodePage/NodeInfoTab.jsx +12 -1
- package/src/app/services/DJService.js +69 -0
- package/src/app/services/__tests__/DJService.test.jsx +100 -0
|
@@ -1,75 +1,288 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A select component for picking metrics.
|
|
3
|
+
* Uses async search to efficiently handle large numbers of metrics.
|
|
4
|
+
* Results are grouped by namespace for easier navigation.
|
|
3
5
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
import React, { useContext, useEffect, useState } from 'react';
|
|
6
|
+
import AsyncSelect, { components } from 'react-select/async';
|
|
7
|
+
import React, { useContext, useEffect, useState, useCallback } from 'react';
|
|
7
8
|
import DJClientContext from '../../providers/djclient';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// Debounce helper
|
|
11
|
+
const debounce = (fn, ms) => {
|
|
12
|
+
let timer;
|
|
13
|
+
return (...args) => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
return new Promise(resolve => {
|
|
16
|
+
timer = setTimeout(() => resolve(fn(...args)), ms);
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
};
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Extract namespace from a fully qualified metric name.
|
|
23
|
+
* e.g., "finance.total_revenue" -> "finance"
|
|
24
|
+
*/
|
|
25
|
+
const getNamespace = metricName => {
|
|
26
|
+
if (!metricName) return 'default';
|
|
27
|
+
const parts = metricName.split('.');
|
|
28
|
+
return parts.slice(0, -1).join('.') || 'default';
|
|
29
|
+
};
|
|
16
30
|
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Git branch icon SVG component
|
|
33
|
+
*/
|
|
34
|
+
const GitBranchIcon = () => (
|
|
35
|
+
<svg
|
|
36
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
37
|
+
width="10"
|
|
38
|
+
height="10"
|
|
39
|
+
viewBox="0 0 24 24"
|
|
40
|
+
fill="none"
|
|
41
|
+
stroke="currentColor"
|
|
42
|
+
strokeWidth="2"
|
|
43
|
+
strokeLinecap="round"
|
|
44
|
+
strokeLinejoin="round"
|
|
45
|
+
>
|
|
46
|
+
<line x1="6" y1="3" x2="6" y2="15" />
|
|
47
|
+
<circle cx="18" cy="6" r="3" />
|
|
48
|
+
<circle cx="6" cy="18" r="3" />
|
|
49
|
+
<path d="M18 9a9 9 0 0 1-9 9" />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
19
52
|
|
|
20
|
-
|
|
21
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Custom Group heading component matching Explorer styling.
|
|
55
|
+
*/
|
|
56
|
+
const GroupHeading = props => {
|
|
57
|
+
const { data } = props;
|
|
58
|
+
const { namespace, gitInfo, count } = data;
|
|
22
59
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
display: 'flex',
|
|
64
|
+
alignItems: 'center',
|
|
65
|
+
padding: '8px 12px',
|
|
66
|
+
backgroundColor: '#fafafa',
|
|
67
|
+
borderBottom: '1px solid #eee',
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<span style={{ fontWeight: 500, color: '#333' }}>{namespace}</span>
|
|
71
|
+
{gitInfo?.branch && (
|
|
72
|
+
<span
|
|
73
|
+
title={`Branch: ${gitInfo.branch}`}
|
|
74
|
+
style={{
|
|
75
|
+
marginLeft: '8px',
|
|
76
|
+
fontSize: '11px',
|
|
77
|
+
padding: '2px 6px',
|
|
78
|
+
borderRadius: '3px',
|
|
79
|
+
backgroundColor: gitInfo.isDefaultBranch ? '#d4edda' : '#fff3cd',
|
|
80
|
+
color: gitInfo.isDefaultBranch ? '#155724' : '#856404',
|
|
81
|
+
display: 'inline-flex',
|
|
82
|
+
alignItems: 'center',
|
|
83
|
+
gap: '4px',
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<GitBranchIcon />
|
|
87
|
+
{gitInfo.branch}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
<span style={{ marginLeft: '8px', color: '#999', fontSize: '12px' }}>
|
|
91
|
+
({count})
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Group flat metrics array into react-select grouped format.
|
|
99
|
+
* Includes branch info for styled group headers.
|
|
100
|
+
*/
|
|
101
|
+
const groupMetricsByNamespace = metrics => {
|
|
102
|
+
const grouped = {};
|
|
103
|
+
const gitInfoByNamespace = {};
|
|
36
104
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const getValue = options => {
|
|
44
|
-
if (options) {
|
|
45
|
-
return options.map(option => option.value);
|
|
46
|
-
} else {
|
|
47
|
-
return [];
|
|
105
|
+
metrics.forEach(metric => {
|
|
106
|
+
const namespace = getNamespace(metric.value);
|
|
107
|
+
if (!grouped[namespace]) {
|
|
108
|
+
grouped[namespace] = [];
|
|
109
|
+
gitInfoByNamespace[namespace] = metric.gitInfo;
|
|
48
110
|
}
|
|
49
|
-
|
|
111
|
+
grouped[namespace].push(metric);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Sort namespaces alphabetically and build grouped options
|
|
115
|
+
return Object.keys(grouped)
|
|
116
|
+
.sort()
|
|
117
|
+
.map(namespace => {
|
|
118
|
+
const gitInfo = gitInfoByNamespace[namespace];
|
|
119
|
+
const count = grouped[namespace].length;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
label: namespace,
|
|
123
|
+
namespace,
|
|
124
|
+
gitInfo,
|
|
125
|
+
count,
|
|
126
|
+
options: grouped[namespace],
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
};
|
|
50
130
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Custom option component that shows display name and full node name.
|
|
133
|
+
*/
|
|
134
|
+
const formatOptionLabel = (option, { context }) => {
|
|
135
|
+
if (context === 'menu') {
|
|
136
|
+
const displayName = option.label;
|
|
137
|
+
const nodeName = option.value;
|
|
138
|
+
const isDifferent = displayName !== nodeName;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div>
|
|
142
|
+
<div>{displayName}</div>
|
|
143
|
+
{isDifferent && (
|
|
144
|
+
<div style={{ fontSize: '12px', color: '#999', marginTop: '2px' }}>
|
|
145
|
+
{nodeName}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
// For selected chips: show display name with tooltip and optional branch badge
|
|
152
|
+
const displayName = option.label || option.value;
|
|
153
|
+
const gitInfo = option.gitInfo;
|
|
154
|
+
const showBranch = gitInfo?.branch && !gitInfo?.isDefaultBranch;
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<span
|
|
158
|
+
title={option.value}
|
|
159
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
|
160
|
+
>
|
|
161
|
+
<span>{displayName}</span>
|
|
162
|
+
{showBranch && (
|
|
163
|
+
<span
|
|
164
|
+
style={{
|
|
165
|
+
fontSize: '9px',
|
|
166
|
+
padding: '1px 4px',
|
|
167
|
+
borderRadius: '2px',
|
|
168
|
+
backgroundColor: 'rgba(255, 255, 255, 0.6)',
|
|
169
|
+
color: '#a2283e',
|
|
170
|
+
border: '1px solid rgba(162, 40, 62, 0.2)',
|
|
171
|
+
fontWeight: 500,
|
|
172
|
+
letterSpacing: '0.2px',
|
|
65
173
|
}}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
174
|
+
>
|
|
175
|
+
⎇ {gitInfo.branch}
|
|
176
|
+
</span>
|
|
177
|
+
)}
|
|
178
|
+
</span>
|
|
179
|
+
);
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const MetricsSelect = React.memo(function MetricsSelect({
|
|
183
|
+
cube,
|
|
184
|
+
onChange,
|
|
185
|
+
}) {
|
|
186
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
187
|
+
|
|
188
|
+
// Currently selected metrics (for controlled component)
|
|
189
|
+
const [selectedMetrics, setSelectedMetrics] = useState([]);
|
|
190
|
+
|
|
191
|
+
// Load existing cube metrics when editing
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (cube?.current?.cubeMetrics) {
|
|
194
|
+
const cubeMetrics = cube.current.cubeMetrics.map(metric => ({
|
|
195
|
+
value: metric.name,
|
|
196
|
+
label: metric.displayName || metric.name,
|
|
197
|
+
}));
|
|
198
|
+
setSelectedMetrics(cubeMetrics);
|
|
199
|
+
onChange(cubeMetrics.map(m => m.value));
|
|
200
|
+
|
|
201
|
+
// Fetch gitInfo for existing metrics so we can display branch badges
|
|
202
|
+
const names = cubeMetrics.map(m => m.value);
|
|
203
|
+
djClient.getMetricsInfo(names).then(enriched => {
|
|
204
|
+
if (enriched.length > 0) {
|
|
205
|
+
const infoByName = Object.fromEntries(
|
|
206
|
+
enriched.map(e => [e.value, e]),
|
|
207
|
+
);
|
|
208
|
+
setSelectedMetrics(prev =>
|
|
209
|
+
prev.map(m => (infoByName[m.value] ? infoByName[m.value] : m)),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
72
213
|
}
|
|
214
|
+
}, [cube, onChange, djClient]);
|
|
215
|
+
|
|
216
|
+
// Async load options - searches metrics via GraphQL, grouped by namespace
|
|
217
|
+
const loadOptions = useCallback(
|
|
218
|
+
debounce(async inputValue => {
|
|
219
|
+
if (!inputValue || inputValue.length < 2) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
try {
|
|
223
|
+
const results = await djClient.searchMetrics(inputValue, 50);
|
|
224
|
+
return groupMetricsByNamespace(results);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
console.error('Error searching metrics:', error);
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
}, 300),
|
|
230
|
+
[djClient],
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const handleChange = selected => {
|
|
234
|
+
setSelectedMetrics(selected || []);
|
|
235
|
+
onChange((selected || []).map(option => option.value));
|
|
73
236
|
};
|
|
74
|
-
|
|
75
|
-
|
|
237
|
+
|
|
238
|
+
// Custom styles to color-code metric tags (matching Query Planner exactly)
|
|
239
|
+
const metricStyles = {
|
|
240
|
+
multiValue: base => ({
|
|
241
|
+
...base,
|
|
242
|
+
backgroundColor: '#fad7dd',
|
|
243
|
+
border: '1px solid rgba(162, 40, 62, 0.3)',
|
|
244
|
+
borderRadius: '3px',
|
|
245
|
+
margin: '2px',
|
|
246
|
+
}),
|
|
247
|
+
multiValueLabel: base => ({
|
|
248
|
+
...base,
|
|
249
|
+
color: '#a2283e',
|
|
250
|
+
fontSize: '10px',
|
|
251
|
+
fontWeight: 500,
|
|
252
|
+
padding: '2px 4px 2px 6px',
|
|
253
|
+
}),
|
|
254
|
+
multiValueRemove: base => ({
|
|
255
|
+
...base,
|
|
256
|
+
color: '#a2283e',
|
|
257
|
+
padding: '0 4px',
|
|
258
|
+
':hover': {
|
|
259
|
+
backgroundColor: '#f5c4cd',
|
|
260
|
+
color: '#a2283e',
|
|
261
|
+
},
|
|
262
|
+
}),
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<AsyncSelect
|
|
267
|
+
value={selectedMetrics}
|
|
268
|
+
loadOptions={loadOptions}
|
|
269
|
+
onChange={handleChange}
|
|
270
|
+
name="metrics"
|
|
271
|
+
placeholder="Type to search metrics..."
|
|
272
|
+
noOptionsMessage={({ inputValue }) =>
|
|
273
|
+
inputValue.length < 2
|
|
274
|
+
? 'Type at least 2 characters to search'
|
|
275
|
+
: 'No metrics found'
|
|
276
|
+
}
|
|
277
|
+
loadingMessage={() => 'Searching...'}
|
|
278
|
+
formatOptionLabel={formatOptionLabel}
|
|
279
|
+
components={{ GroupHeading }}
|
|
280
|
+
styles={metricStyles}
|
|
281
|
+
isMulti
|
|
282
|
+
isClearable
|
|
283
|
+
closeMenuOnSelect={false}
|
|
284
|
+
cacheOptions
|
|
285
|
+
defaultOptions={false}
|
|
286
|
+
/>
|
|
287
|
+
);
|
|
288
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { CubePreviewPanel } from '../CubePreviewPanel';
|
|
3
|
+
import DJClientContext from '../../../providers/djclient';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
const renderPanel = ({ djClient, initialValues }) =>
|
|
7
|
+
render(
|
|
8
|
+
<DJClientContext.Provider value={{ DataJunctionAPI: djClient }}>
|
|
9
|
+
<CubePreviewPanel
|
|
10
|
+
metrics={initialValues.metrics}
|
|
11
|
+
dimensions={initialValues.dimensions}
|
|
12
|
+
/>
|
|
13
|
+
</DJClientContext.Provider>,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
describe('CubePreviewPanel', () => {
|
|
17
|
+
it('shows the empty state when no metrics or dimensions are selected', () => {
|
|
18
|
+
const djClient = { metricsV3: jest.fn() };
|
|
19
|
+
renderPanel({ djClient, initialValues: { metrics: [], dimensions: [] } });
|
|
20
|
+
|
|
21
|
+
expect(
|
|
22
|
+
screen.getByText('Select metrics and dimensions to preview SQL'),
|
|
23
|
+
).toBeInTheDocument();
|
|
24
|
+
// No SQL fetch should be issued for an empty selection.
|
|
25
|
+
expect(djClient.metricsV3).not.toHaveBeenCalled();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders the generated SQL once metricsV3 resolves', async () => {
|
|
29
|
+
const djClient = {
|
|
30
|
+
metricsV3: jest.fn().mockResolvedValue({ sql: 'SELECT 1', errors: [] }),
|
|
31
|
+
};
|
|
32
|
+
renderPanel({
|
|
33
|
+
djClient,
|
|
34
|
+
initialValues: {
|
|
35
|
+
metrics: ['default.revenue'],
|
|
36
|
+
dimensions: ['default.date'],
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// The fetch is debounced 500ms — wait for it via real time.
|
|
41
|
+
await waitFor(
|
|
42
|
+
() =>
|
|
43
|
+
expect(djClient.metricsV3).toHaveBeenCalledWith(
|
|
44
|
+
['default.revenue'],
|
|
45
|
+
['default.date'],
|
|
46
|
+
'',
|
|
47
|
+
),
|
|
48
|
+
{ timeout: 1500 },
|
|
49
|
+
);
|
|
50
|
+
// Once SQL arrives, the empty / loading / error states all disappear.
|
|
51
|
+
await waitFor(
|
|
52
|
+
() =>
|
|
53
|
+
expect(
|
|
54
|
+
screen.queryByText('Select metrics and dimensions to preview SQL'),
|
|
55
|
+
).not.toBeInTheDocument(),
|
|
56
|
+
{ timeout: 1500 },
|
|
57
|
+
);
|
|
58
|
+
expect(screen.queryByText('Generating SQL...')).not.toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('surfaces API errors returned alongside a 200 response', async () => {
|
|
62
|
+
const djClient = {
|
|
63
|
+
metricsV3: jest.fn().mockResolvedValue({ errors: ['boom'] }),
|
|
64
|
+
};
|
|
65
|
+
renderPanel({
|
|
66
|
+
djClient,
|
|
67
|
+
initialValues: {
|
|
68
|
+
metrics: ['default.revenue'],
|
|
69
|
+
dimensions: ['default.date'],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
expect(
|
|
73
|
+
await screen.findByText('boom', {}, { timeout: 1500 }),
|
|
74
|
+
).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('surfaces the message field when the API returns it instead of sql', async () => {
|
|
78
|
+
const djClient = {
|
|
79
|
+
metricsV3: jest.fn().mockResolvedValue({ message: 'no metrics' }),
|
|
80
|
+
};
|
|
81
|
+
renderPanel({
|
|
82
|
+
djClient,
|
|
83
|
+
initialValues: {
|
|
84
|
+
metrics: ['default.revenue'],
|
|
85
|
+
dimensions: ['default.date'],
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(
|
|
89
|
+
await screen.findByText('no metrics', {}, { timeout: 1500 }),
|
|
90
|
+
).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('surfaces thrown errors from metricsV3', async () => {
|
|
94
|
+
const djClient = {
|
|
95
|
+
metricsV3: jest.fn().mockRejectedValue(new Error('network down')),
|
|
96
|
+
};
|
|
97
|
+
renderPanel({
|
|
98
|
+
djClient,
|
|
99
|
+
initialValues: {
|
|
100
|
+
metrics: ['default.revenue'],
|
|
101
|
+
dimensions: ['default.date'],
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
expect(
|
|
105
|
+
await screen.findByText('network down', {}, { timeout: 1500 }),
|
|
106
|
+
).toBeInTheDocument();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
2
|
+
import { DimensionsSelect } from '../DimensionsSelect';
|
|
3
|
+
import DJClientContext from '../../../providers/djclient';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
const renderInForm = ({
|
|
7
|
+
djClient,
|
|
8
|
+
cube,
|
|
9
|
+
initialValues = { metrics: [], dimensions: [] },
|
|
10
|
+
onChange = () => {},
|
|
11
|
+
}) =>
|
|
12
|
+
render(
|
|
13
|
+
<DJClientContext.Provider value={{ DataJunctionAPI: djClient }}>
|
|
14
|
+
<DimensionsSelect
|
|
15
|
+
cube={cube}
|
|
16
|
+
metrics={initialValues.metrics}
|
|
17
|
+
onChange={onChange}
|
|
18
|
+
/>
|
|
19
|
+
</DJClientContext.Provider>,
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
describe('DimensionsSelect', () => {
|
|
23
|
+
it('renders nothing when no metrics are selected', () => {
|
|
24
|
+
const djClient = { commonDimensions: jest.fn() };
|
|
25
|
+
const { container } = renderInForm({ djClient });
|
|
26
|
+
expect(container.firstChild).toBeNull();
|
|
27
|
+
expect(djClient.commonDimensions).not.toHaveBeenCalled();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('groups dimensions by hop distance', async () => {
|
|
31
|
+
const djClient = {
|
|
32
|
+
commonDimensions: jest.fn().mockResolvedValue([
|
|
33
|
+
// Direct dimension (path length 0)
|
|
34
|
+
{
|
|
35
|
+
name: 'default.event.event_type',
|
|
36
|
+
node_name: 'default.event',
|
|
37
|
+
node_display_name: 'Event',
|
|
38
|
+
attribute: 'event_type',
|
|
39
|
+
properties: [],
|
|
40
|
+
path: [],
|
|
41
|
+
},
|
|
42
|
+
// 2-hop dimension
|
|
43
|
+
{
|
|
44
|
+
name: 'default.user.country',
|
|
45
|
+
node_name: 'default.user',
|
|
46
|
+
node_display_name: 'User',
|
|
47
|
+
attribute: 'country',
|
|
48
|
+
properties: [],
|
|
49
|
+
path: ['default.event.user_id', 'default.user.id'],
|
|
50
|
+
},
|
|
51
|
+
]),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
renderInForm({
|
|
55
|
+
djClient,
|
|
56
|
+
initialValues: {
|
|
57
|
+
metrics: ['default.events'],
|
|
58
|
+
dimensions: [],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Hop labels render once the fetch resolves.
|
|
63
|
+
expect(await screen.findByText('Direct Dimensions')).toBeInTheDocument();
|
|
64
|
+
expect(await screen.findByText('2 Hops Away')).toBeInTheDocument();
|
|
65
|
+
|
|
66
|
+
await waitFor(() =>
|
|
67
|
+
expect(djClient.commonDimensions).toHaveBeenCalledWith([
|
|
68
|
+
'default.events',
|
|
69
|
+
]),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('uses singular "1 Hop Away" label for path length 1', async () => {
|
|
74
|
+
const djClient = {
|
|
75
|
+
commonDimensions: jest.fn().mockResolvedValue([
|
|
76
|
+
{
|
|
77
|
+
name: 'default.user.country',
|
|
78
|
+
node_name: 'default.user',
|
|
79
|
+
node_display_name: 'User',
|
|
80
|
+
attribute: 'country',
|
|
81
|
+
properties: [],
|
|
82
|
+
path: ['default.event.user_id'],
|
|
83
|
+
},
|
|
84
|
+
]),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
renderInForm({
|
|
88
|
+
djClient,
|
|
89
|
+
initialValues: { metrics: ['default.events'], dimensions: [] },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(await screen.findByText('1 Hop Away')).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('pre-fills selected dimensions when editing an existing cube', async () => {
|
|
96
|
+
const djClient = {
|
|
97
|
+
commonDimensions: jest.fn().mockResolvedValue([
|
|
98
|
+
{
|
|
99
|
+
name: 'default.event.event_type',
|
|
100
|
+
node_name: 'default.event',
|
|
101
|
+
node_display_name: 'Event',
|
|
102
|
+
attribute: 'event_type',
|
|
103
|
+
properties: ['primary_key'],
|
|
104
|
+
path: [],
|
|
105
|
+
},
|
|
106
|
+
]),
|
|
107
|
+
};
|
|
108
|
+
const cube = {
|
|
109
|
+
current: {
|
|
110
|
+
cubeDimensions: [
|
|
111
|
+
{
|
|
112
|
+
name: 'default.event.event_type',
|
|
113
|
+
attribute: 'event_type',
|
|
114
|
+
properties: ['primary_key'],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
renderInForm({
|
|
121
|
+
djClient,
|
|
122
|
+
cube,
|
|
123
|
+
initialValues: { metrics: ['default.events'], dimensions: [] },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// The PK suffix is appended to the chip label.
|
|
127
|
+
expect(
|
|
128
|
+
await screen.findByText(content => content.includes('(PK)')),
|
|
129
|
+
).toBeInTheDocument();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('shows the role suffix on chip labels for role-aliased cube dimensions', async () => {
|
|
133
|
+
const djClient = {
|
|
134
|
+
commonDimensions: jest.fn().mockResolvedValue([
|
|
135
|
+
{
|
|
136
|
+
name: 'default.user_dim.country_code[birth_country]',
|
|
137
|
+
node_name: 'default.user_dim',
|
|
138
|
+
node_display_name: 'User Dim',
|
|
139
|
+
attribute: 'country_code',
|
|
140
|
+
properties: [],
|
|
141
|
+
path: [],
|
|
142
|
+
},
|
|
143
|
+
]),
|
|
144
|
+
};
|
|
145
|
+
const cube = {
|
|
146
|
+
current: {
|
|
147
|
+
cubeDimensions: [
|
|
148
|
+
{
|
|
149
|
+
name: 'default.user_dim.country_code[birth_country]',
|
|
150
|
+
attribute: 'country_code',
|
|
151
|
+
role: 'birth_country',
|
|
152
|
+
properties: [],
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
renderInForm({
|
|
159
|
+
djClient,
|
|
160
|
+
cube,
|
|
161
|
+
initialValues: { metrics: ['default.users'], dimensions: [] },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Role surfaces as a "[birth_country]" suffix on the labelized attribute.
|
|
165
|
+
expect(
|
|
166
|
+
await screen.findByText(content =>
|
|
167
|
+
content.includes('Country Code [birth_country]'),
|
|
168
|
+
),
|
|
169
|
+
).toBeInTheDocument();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('renders the role suffix on the chip even for selected role-aliased options', async () => {
|
|
173
|
+
// Two role-aliased instances of the same attribute show as two distinct
|
|
174
|
+
// chips, distinguishable by the role suffix in the label.
|
|
175
|
+
const djClient = {
|
|
176
|
+
commonDimensions: jest.fn().mockResolvedValue([
|
|
177
|
+
{
|
|
178
|
+
name: 'default.user_dim.country_code[birth_country]',
|
|
179
|
+
node_name: 'default.user_dim',
|
|
180
|
+
node_display_name: 'User Dim',
|
|
181
|
+
attribute: 'country_code',
|
|
182
|
+
properties: [],
|
|
183
|
+
path: [],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: 'default.user_dim.country_code[residence_country]',
|
|
187
|
+
node_name: 'default.user_dim',
|
|
188
|
+
node_display_name: 'User Dim',
|
|
189
|
+
attribute: 'country_code',
|
|
190
|
+
properties: [],
|
|
191
|
+
path: [],
|
|
192
|
+
},
|
|
193
|
+
]),
|
|
194
|
+
};
|
|
195
|
+
const cube = {
|
|
196
|
+
current: {
|
|
197
|
+
cubeDimensions: [
|
|
198
|
+
{
|
|
199
|
+
name: 'default.user_dim.country_code[birth_country]',
|
|
200
|
+
attribute: 'country_code',
|
|
201
|
+
role: 'birth_country',
|
|
202
|
+
properties: [],
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: 'default.user_dim.country_code[residence_country]',
|
|
206
|
+
attribute: 'country_code',
|
|
207
|
+
role: 'residence_country',
|
|
208
|
+
properties: [],
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
renderInForm({
|
|
215
|
+
djClient,
|
|
216
|
+
cube,
|
|
217
|
+
initialValues: { metrics: ['default.users'], dimensions: [] },
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(
|
|
221
|
+
await screen.findByText(c => c.includes('Country Code [birth_country]')),
|
|
222
|
+
).toBeInTheDocument();
|
|
223
|
+
expect(
|
|
224
|
+
await screen.findByText(c =>
|
|
225
|
+
c.includes('Country Code [residence_country]'),
|
|
226
|
+
),
|
|
227
|
+
).toBeInTheDocument();
|
|
228
|
+
});
|
|
229
|
+
});
|