datajunction-ui 0.0.143 → 0.0.145
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/NamespacePage/__tests__/index.test.jsx +4 -4
- package/src/app/pages/NamespacePage/index.jsx +12 -8
- package/src/app/pages/NodePage/NodeInfoTab.jsx +12 -1
- package/src/app/services/DJService.js +70 -0
- package/src/app/services/__tests__/DJService.test.jsx +100 -0
package/package.json
CHANGED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview panel for cube builder showing selection summary and generated SQL.
|
|
3
|
+
* Matches Query Planner styling exactly.
|
|
4
|
+
*/
|
|
5
|
+
import React, {
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
useCallback,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
13
|
+
import sql from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
|
|
14
|
+
import { atomOneLight } from 'react-syntax-highlighter/dist/esm/styles/hljs';
|
|
15
|
+
import DJClientContext from '../../providers/djclient';
|
|
16
|
+
import {
|
|
17
|
+
formatBytes,
|
|
18
|
+
formatScanEstimate,
|
|
19
|
+
} from '../QueryPlannerPage/PreAggDetailsPanel';
|
|
20
|
+
|
|
21
|
+
SyntaxHighlighter.registerLanguage('sql', sql);
|
|
22
|
+
|
|
23
|
+
const debounce = (fn, ms) => {
|
|
24
|
+
let timer;
|
|
25
|
+
return (...args) => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
timer = setTimeout(() => fn(...args), ms);
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const CubePreviewPanel = React.memo(function CubePreviewPanel({
|
|
32
|
+
metrics = [],
|
|
33
|
+
dimensions = [],
|
|
34
|
+
}) {
|
|
35
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
36
|
+
const [result, setResult] = useState(null);
|
|
37
|
+
const [loading, setLoading] = useState(false);
|
|
38
|
+
const [error, setError] = useState(null);
|
|
39
|
+
|
|
40
|
+
// Fetch SQL when metrics/dimensions change
|
|
41
|
+
const fetchSql = useCallback(
|
|
42
|
+
debounce(async (m, d) => {
|
|
43
|
+
if (m.length === 0 || d.length === 0) {
|
|
44
|
+
setResult(null);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
setLoading(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
try {
|
|
50
|
+
const res = await djClient.metricsV3(m, d, '');
|
|
51
|
+
if (res.errors && res.errors.length > 0) {
|
|
52
|
+
setError(
|
|
53
|
+
res.errors
|
|
54
|
+
.map(e =>
|
|
55
|
+
typeof e === 'string' ? e : e.message || JSON.stringify(e),
|
|
56
|
+
)
|
|
57
|
+
.join(', '),
|
|
58
|
+
);
|
|
59
|
+
setResult(null);
|
|
60
|
+
} else if (res.message) {
|
|
61
|
+
setError(res.message);
|
|
62
|
+
setResult(null);
|
|
63
|
+
} else {
|
|
64
|
+
setResult(res);
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
setError(err.message || 'Failed to generate SQL');
|
|
68
|
+
setResult(null);
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
}, 500),
|
|
73
|
+
[djClient],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
fetchSql(metrics, dimensions);
|
|
78
|
+
}, [metrics, dimensions, fetchSql]);
|
|
79
|
+
|
|
80
|
+
// Get short name from full metric/dimension name
|
|
81
|
+
const getShortName = fullName => {
|
|
82
|
+
if (!fullName) return '';
|
|
83
|
+
const parts = fullName.split('.');
|
|
84
|
+
return parts[parts.length - 1];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const scanInfo = formatScanEstimate(result?.scan_estimate);
|
|
88
|
+
|
|
89
|
+
// SyntaxHighlighter is the heaviest piece of DOM in the form — re-rendering
|
|
90
|
+
// it on every keystroke makes typing in unrelated fields feel laggy. Memo
|
|
91
|
+
// by `result?.sql` so the highlighted output is reused as long as the SQL
|
|
92
|
+
// hasn't changed.
|
|
93
|
+
const highlightedSql = useMemo(() => {
|
|
94
|
+
if (!result?.sql) return null;
|
|
95
|
+
return (
|
|
96
|
+
<SyntaxHighlighter
|
|
97
|
+
language="sql"
|
|
98
|
+
style={atomOneLight}
|
|
99
|
+
customStyle={{
|
|
100
|
+
margin: 0,
|
|
101
|
+
padding: 0,
|
|
102
|
+
fontSize: '11px',
|
|
103
|
+
background: 'transparent',
|
|
104
|
+
border: 'none',
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{result.sql}
|
|
108
|
+
</SyntaxHighlighter>
|
|
109
|
+
);
|
|
110
|
+
}, [result?.sql]);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="cube-preview-panel">
|
|
114
|
+
<div className="preview-section-header">
|
|
115
|
+
<span className="preview-section-icon">⌘</span>
|
|
116
|
+
<span className="preview-section-title">Generated SQL</span>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/* Scan Cost Banner */}
|
|
120
|
+
{scanInfo && (
|
|
121
|
+
<div className={`scan-estimate-banner scan-estimate-${scanInfo.level}`}>
|
|
122
|
+
<span className="scan-estimate-icon">{scanInfo.icon}</span>
|
|
123
|
+
<div className="scan-estimate-content">
|
|
124
|
+
<div className="scan-estimate-header">
|
|
125
|
+
<strong>Scan Cost:</strong>{' '}
|
|
126
|
+
{scanInfo.totalBytes !== null && scanInfo.totalBytes !== undefined
|
|
127
|
+
? (scanInfo.hasMissingData ? '≥ ' : '') +
|
|
128
|
+
formatBytes(scanInfo.totalBytes)
|
|
129
|
+
: 'Unknown'}
|
|
130
|
+
</div>
|
|
131
|
+
<div className="scan-estimate-sources">
|
|
132
|
+
{scanInfo.sources.map((source, idx) => {
|
|
133
|
+
let displayName = source.source_name;
|
|
134
|
+
if (source.schema_ && source.table) {
|
|
135
|
+
displayName = `${source.schema_}.${source.table}`;
|
|
136
|
+
} else if (source.table) {
|
|
137
|
+
displayName = source.table;
|
|
138
|
+
}
|
|
139
|
+
return (
|
|
140
|
+
<div key={idx} className="scan-source-item">
|
|
141
|
+
<span
|
|
142
|
+
className="scan-source-name"
|
|
143
|
+
title={source.source_name}
|
|
144
|
+
>
|
|
145
|
+
{displayName}
|
|
146
|
+
</span>
|
|
147
|
+
<span className="scan-source-size">
|
|
148
|
+
{source.total_bytes !== null &&
|
|
149
|
+
source.total_bytes !== undefined
|
|
150
|
+
? formatBytes(source.total_bytes)
|
|
151
|
+
: 'no size data'}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
<div className="preview-sql-container">
|
|
162
|
+
{loading && <div className="preview-loading">Generating SQL...</div>}
|
|
163
|
+
{error && <div className="preview-error">{error}</div>}
|
|
164
|
+
{!loading && !error && !result?.sql && (
|
|
165
|
+
<div className="preview-empty">
|
|
166
|
+
Select metrics and dimensions to preview SQL
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
{!loading && !error && highlightedSql}
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
});
|
|
@@ -1,24 +1,64 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A select component for picking dimensions
|
|
2
|
+
* A select component for picking dimensions.
|
|
3
|
+
* Dimensions are grouped by hop distance (how many joins away from the metrics).
|
|
3
4
|
*/
|
|
4
|
-
import { useField, useFormikContext } from 'formik';
|
|
5
5
|
import Select from 'react-select';
|
|
6
6
|
import React, { useContext, useEffect, useState } from 'react';
|
|
7
7
|
import DJClientContext from '../../providers/djclient';
|
|
8
8
|
import { labelize } from '../../../utils/form';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Calculate hop distance from path length.
|
|
12
|
+
* path.length represents how many joins away the dimension is.
|
|
13
|
+
*/
|
|
14
|
+
const getHopDistance = path => {
|
|
15
|
+
if (!path || path.length === 0) return 0;
|
|
16
|
+
return path.length;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render role information as a label suffix when a dimension is reached
|
|
21
|
+
* via a named role (e.g. "[birth_country]"). Stored role values can be raw
|
|
22
|
+
* (e.g. "birth_country") or already bracketed; normalize either form to
|
|
23
|
+
* " [role]". Returns "" when there is no role.
|
|
24
|
+
*/
|
|
25
|
+
const formatRoleSuffix = role => {
|
|
26
|
+
if (!role) return '';
|
|
27
|
+
const stripped = role.replace(/^\[|\]$/g, '');
|
|
28
|
+
return stripped ? ` [${stripped}]` : '';
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a role suffix off the end of a dimension's full name.
|
|
33
|
+
* "default.user_dim.country_code[birth_country]" → ["default.user_dim.country_code", "birth_country"]
|
|
34
|
+
* "default.user_dim.country_code" → ["default.user_dim.country_code", ""]
|
|
35
|
+
*/
|
|
36
|
+
const splitRole = fullName => {
|
|
37
|
+
if (!fullName) return [fullName, ''];
|
|
38
|
+
const match = fullName.match(/^(.+?)\[([^\]]+)\]$/);
|
|
39
|
+
return match ? [match[1], match[2]] : [fullName, ''];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get human-readable label for hop distance.
|
|
44
|
+
*/
|
|
45
|
+
const getHopLabel = hopDistance => {
|
|
46
|
+
if (hopDistance === 0) return 'Direct Dimensions';
|
|
47
|
+
if (hopDistance === 1) return '1 Hop Away';
|
|
48
|
+
return `${hopDistance} Hops Away`;
|
|
49
|
+
};
|
|
13
50
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
51
|
+
export const DimensionsSelect = React.memo(function DimensionsSelect({
|
|
52
|
+
cube,
|
|
53
|
+
metrics,
|
|
54
|
+
onChange,
|
|
55
|
+
}) {
|
|
56
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
17
57
|
|
|
18
|
-
//
|
|
19
|
-
const [
|
|
58
|
+
// Dimensions grouped by hop distance, then by node+path
|
|
59
|
+
const [dimensionsByHop, setDimensionsByHop] = useState({});
|
|
20
60
|
|
|
21
|
-
// The selected dimensions,
|
|
61
|
+
// The selected dimensions, grouped by dimension node and path
|
|
22
62
|
const [selectedDimensionsByGroup, setSelectedDimensionsByGroup] = useState(
|
|
23
63
|
{},
|
|
24
64
|
);
|
|
@@ -35,52 +75,63 @@ export const DimensionsSelect = ({ cube }) => {
|
|
|
35
75
|
value: cubeDim.name,
|
|
36
76
|
label:
|
|
37
77
|
labelize(cubeDim.attribute) +
|
|
78
|
+
formatRoleSuffix(cubeDim.role) +
|
|
38
79
|
(cubeDim.properties?.includes('primary_key') ? ' (PK)' : ''),
|
|
39
80
|
};
|
|
40
81
|
});
|
|
41
82
|
setDefaultDimensions(cubeDimensions);
|
|
42
|
-
|
|
83
|
+
onChange(cubeDimensions.map(m => m.value));
|
|
43
84
|
}
|
|
44
85
|
|
|
45
|
-
if (
|
|
86
|
+
if (metrics && metrics.length > 0) {
|
|
46
87
|
// Populate the common dimensions list based on the selected metrics
|
|
47
|
-
const commonDimensions = await djClient.commonDimensions(
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
group[
|
|
88
|
+
const commonDimensions = await djClient.commonDimensions(metrics);
|
|
89
|
+
|
|
90
|
+
// First group by node_name + path (original grouping)
|
|
91
|
+
const groupedByNodePath = commonDimensions.reduce(
|
|
92
|
+
(group, dimension) => {
|
|
93
|
+
const key = dimension.node_name + JSON.stringify(dimension.path);
|
|
94
|
+
group[key] = group[key] ?? [];
|
|
95
|
+
group[key].push(dimension);
|
|
55
96
|
return group;
|
|
56
|
-
},
|
|
97
|
+
},
|
|
98
|
+
{},
|
|
57
99
|
);
|
|
58
|
-
|
|
100
|
+
|
|
101
|
+
// Then organize by hop distance
|
|
102
|
+
const byHop = {};
|
|
103
|
+
Object.values(groupedByNodePath).forEach(dimensionsInGroup => {
|
|
104
|
+
const hopDistance = getHopDistance(dimensionsInGroup[0].path);
|
|
105
|
+
byHop[hopDistance] = byHop[hopDistance] ?? [];
|
|
106
|
+
byHop[hopDistance].push(dimensionsInGroup);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
setDimensionsByHop(byHop);
|
|
59
110
|
|
|
60
111
|
// Set the selected cube dimensions if an existing cube is being edited
|
|
61
112
|
if (cube) {
|
|
62
113
|
const currentSelectedDimensionsByGroup = {};
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
setSelectedDimensionsByGroup(currentSelectedDimensionsByGroup);
|
|
75
|
-
setValue(Object.values(currentSelectedDimensionsByGroup).flat(2));
|
|
114
|
+
Object.values(groupedByNodePath).forEach(dimensionsInGroup => {
|
|
115
|
+
const groupKey =
|
|
116
|
+
dimensionsInGroup[0].node_name +
|
|
117
|
+
JSON.stringify(dimensionsInGroup[0].path);
|
|
118
|
+
currentSelectedDimensionsByGroup[groupKey] = getValue(
|
|
119
|
+
cubeDimensions.filter(
|
|
120
|
+
dim =>
|
|
121
|
+
dimensionsInGroup.filter(x => dim.value === x.name).length >
|
|
122
|
+
0,
|
|
123
|
+
),
|
|
124
|
+
);
|
|
76
125
|
});
|
|
126
|
+
setSelectedDimensionsByGroup(currentSelectedDimensionsByGroup);
|
|
127
|
+
onChange(Object.values(currentSelectedDimensionsByGroup).flat(2));
|
|
77
128
|
}
|
|
78
129
|
} else {
|
|
79
|
-
|
|
130
|
+
setDimensionsByHop({});
|
|
80
131
|
}
|
|
81
132
|
};
|
|
82
133
|
fetchData().catch(console.error);
|
|
83
|
-
}, [djClient,
|
|
134
|
+
}, [djClient, onChange, metrics, cube]);
|
|
84
135
|
|
|
85
136
|
// Retrieves the selected values as a list (since it is a multi-select)
|
|
86
137
|
const getValue = options => {
|
|
@@ -91,62 +142,182 @@ export const DimensionsSelect = ({ cube }) => {
|
|
|
91
142
|
}
|
|
92
143
|
};
|
|
93
144
|
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
145
|
+
// Sort hop distances numerically
|
|
146
|
+
const sortedHops = Object.keys(dimensionsByHop)
|
|
147
|
+
.map(Number)
|
|
148
|
+
.sort((a, b) => a - b);
|
|
149
|
+
|
|
150
|
+
// Custom styles to color-code dimension tags (matching Query Planner exactly)
|
|
151
|
+
const dimensionStyles = {
|
|
152
|
+
multiValue: base => ({
|
|
153
|
+
...base,
|
|
154
|
+
backgroundColor: '#ffefd0',
|
|
155
|
+
border: '1px solid rgba(169, 102, 33, 0.3)',
|
|
156
|
+
borderRadius: '3px',
|
|
157
|
+
margin: '2px',
|
|
158
|
+
}),
|
|
159
|
+
multiValueLabel: base => ({
|
|
160
|
+
...base,
|
|
161
|
+
color: '#a96621',
|
|
162
|
+
fontSize: '10px',
|
|
163
|
+
fontWeight: 500,
|
|
164
|
+
padding: '2px 4px 2px 6px',
|
|
165
|
+
}),
|
|
166
|
+
multiValueRemove: base => ({
|
|
167
|
+
...base,
|
|
168
|
+
color: '#a96621',
|
|
169
|
+
padding: '0 4px',
|
|
170
|
+
':hover': {
|
|
171
|
+
backgroundColor: '#ffe4b3',
|
|
172
|
+
color: '#a96621',
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (sortedHops.length === 0) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div>
|
|
183
|
+
{sortedHops.map(hopDistance => {
|
|
184
|
+
const groupsAtHop = dimensionsByHop[hopDistance];
|
|
185
|
+
const dimensionCount = groupsAtHop.reduce(
|
|
186
|
+
(sum, g) => sum + g.length,
|
|
187
|
+
0,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div key={hopDistance} style={{ marginBottom: '20px' }}>
|
|
192
|
+
{/* Hop distance header */}
|
|
193
|
+
<div
|
|
194
|
+
style={{
|
|
195
|
+
display: 'flex',
|
|
196
|
+
alignItems: 'center',
|
|
197
|
+
padding: '6px 0',
|
|
198
|
+
borderBottom: '1px solid #dee2e6',
|
|
199
|
+
marginBottom: '8px',
|
|
200
|
+
marginTop: hopDistance > 0 ? '24px' : '0',
|
|
201
|
+
}}
|
|
202
|
+
>
|
|
203
|
+
<span
|
|
204
|
+
style={{
|
|
205
|
+
fontWeight: 600,
|
|
206
|
+
color: '#212529',
|
|
207
|
+
fontSize: '14px',
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
{getHopLabel(hopDistance)}
|
|
211
|
+
</span>
|
|
212
|
+
<span
|
|
213
|
+
style={{
|
|
214
|
+
marginLeft: '10px',
|
|
215
|
+
color: '#6c757d',
|
|
216
|
+
fontSize: '13px',
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
({dimensionCount} dimension{dimensionCount !== 1 ? 's' : ''})
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{/* Dimension groups within this hop - indented */}
|
|
224
|
+
<div style={{ paddingLeft: '6px' }}>
|
|
225
|
+
{groupsAtHop.map(dimensionsInGroup => {
|
|
226
|
+
const groupKey =
|
|
227
|
+
dimensionsInGroup[0].node_name +
|
|
228
|
+
JSON.stringify(dimensionsInGroup[0].path);
|
|
229
|
+
|
|
230
|
+
// Convert path node names to display names
|
|
231
|
+
const pathDisplayNames = dimensionsInGroup[0].path.map(
|
|
232
|
+
nodeName => {
|
|
233
|
+
const lastSegment = nodeName.split('.').pop();
|
|
234
|
+
return labelize(lastSegment);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const groupHeader = (
|
|
239
|
+
<h5
|
|
240
|
+
style={{
|
|
241
|
+
fontWeight: 'normal',
|
|
242
|
+
marginBottom: '5px',
|
|
243
|
+
marginTop: '10px',
|
|
244
|
+
fontSize: '13px',
|
|
245
|
+
}}
|
|
246
|
+
title={dimensionsInGroup[0].path.join(' → ')}
|
|
247
|
+
>
|
|
248
|
+
<a href={`/nodes/${dimensionsInGroup[0].node_name}`}>
|
|
249
|
+
<b>{dimensionsInGroup[0].node_display_name}</b>
|
|
250
|
+
</a>
|
|
251
|
+
{pathDisplayNames.length > 0 && (
|
|
252
|
+
<>
|
|
253
|
+
{' '}
|
|
254
|
+
<span style={{ color: '#6c757d' }}>via</span>{' '}
|
|
255
|
+
{pathDisplayNames.map((displayName, idx) => (
|
|
256
|
+
<span key={idx}>
|
|
257
|
+
{idx > 0 && ' → '}
|
|
258
|
+
<a
|
|
259
|
+
href={`/nodes/${dimensionsInGroup[0].path[idx]}`}
|
|
260
|
+
>
|
|
261
|
+
{displayName}
|
|
262
|
+
</a>
|
|
263
|
+
</span>
|
|
264
|
+
))}
|
|
265
|
+
</>
|
|
266
|
+
)}
|
|
267
|
+
</h5>
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const dimensionGroupOptions = dimensionsInGroup.map(dim => {
|
|
271
|
+
const [bareName, role] = splitRole(dim.name);
|
|
272
|
+
return {
|
|
273
|
+
value: dim.name,
|
|
274
|
+
label:
|
|
275
|
+
labelize(bareName.split('.').slice(-1)[0]) +
|
|
276
|
+
formatRoleSuffix(role) +
|
|
277
|
+
(dim.properties?.includes('primary_key') ? ' (PK)' : ''),
|
|
278
|
+
};
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const cubeDimensions = defaultDimensions.filter(
|
|
282
|
+
dim =>
|
|
283
|
+
dimensionGroupOptions.filter(x => dim.value === x.value)
|
|
284
|
+
.length > 0,
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<div key={groupKey}>
|
|
289
|
+
{groupHeader}
|
|
290
|
+
<span
|
|
291
|
+
data-testid={
|
|
292
|
+
'dimensions-' + dimensionsInGroup[0].node_name
|
|
293
|
+
}
|
|
294
|
+
>
|
|
295
|
+
<Select
|
|
296
|
+
className=""
|
|
297
|
+
name={'dimensions-' + groupKey}
|
|
298
|
+
defaultValue={cubeDimensions}
|
|
299
|
+
options={dimensionGroupOptions}
|
|
300
|
+
styles={dimensionStyles}
|
|
301
|
+
isMulti
|
|
302
|
+
isClearable
|
|
303
|
+
closeMenuOnSelect={false}
|
|
304
|
+
onChange={selected => {
|
|
305
|
+
const newSelected = {
|
|
306
|
+
...selectedDimensionsByGroup,
|
|
307
|
+
[groupKey]: getValue(selected),
|
|
308
|
+
};
|
|
309
|
+
setSelectedDimensionsByGroup(newSelected);
|
|
310
|
+
onChange(Object.values(newSelected).flat(2));
|
|
311
|
+
}}
|
|
312
|
+
/>
|
|
313
|
+
</span>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
})}
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
})}
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
});
|