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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.143",
3
+ "version": "0.0.145",
4
4
  "description": "DataJunction UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -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
- export const DimensionsSelect = ({ cube }) => {
11
- const djClient = useContext(DJClientContext).DataJunctionAPI;
12
- const { values, setFieldValue } = useFormikContext();
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
- // eslint-disable-next-line no-unused-vars
15
- const [field, _, helpers] = useField('dimensions');
16
- const { setValue } = helpers;
51
+ export const DimensionsSelect = React.memo(function DimensionsSelect({
52
+ cube,
53
+ metrics,
54
+ onChange,
55
+ }) {
56
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
17
57
 
18
- // All common dimensions for the selected metrics, grouped by the dimension node and path
19
- const [allDimensionsOptions, setAllDimensionsOptions] = useState([]);
58
+ // Dimensions grouped by hop distance, then by node+path
59
+ const [dimensionsByHop, setDimensionsByHop] = useState({});
20
60
 
21
- // The selected dimensions, also grouped by dimension node and path
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
- setValue(cubeDimensions.map(m => m.value));
83
+ onChange(cubeDimensions.map(m => m.value));
43
84
  }
44
85
 
45
- if (values.metrics && values.metrics.length > 0) {
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
- values.metrics,
49
- );
50
- const grouped = Object.entries(
51
- commonDimensions.reduce((group, dimension) => {
52
- group[dimension.node_name + dimension.path] =
53
- group[dimension.node_name + dimension.path] ?? [];
54
- group[dimension.node_name + dimension.path].push(dimension);
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
- setAllDimensionsOptions(grouped);
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
- grouped.forEach(grouping => {
64
- const dimensionsInGroup = grouping[1];
65
- currentSelectedDimensionsByGroup[dimensionsInGroup[0].node_name] =
66
- getValue(
67
- cubeDimensions.filter(
68
- dim =>
69
- dimensionsInGroup.filter(x => {
70
- return dim.value === x.name;
71
- }).length > 0,
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
- setAllDimensionsOptions([]);
130
+ setDimensionsByHop({});
80
131
  }
81
132
  };
82
133
  fetchData().catch(console.error);
83
- }, [djClient, setFieldValue, setValue, values.metrics, cube]);
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
- // Builds the block of dimensions selectors, grouped by node name + path
95
- return allDimensionsOptions.map(grouping => {
96
- const dimensionsInGroup = grouping[1];
97
- const groupHeader = (
98
- <h5
99
- style={{
100
- fontWeight: 'normal',
101
- marginBottom: '5px',
102
- marginTop: '15px',
103
- }}
104
- >
105
- <a href={`/nodes/${dimensionsInGroup[0].node_name}`}>
106
- <b>{dimensionsInGroup[0].node_display_name}</b>
107
- </a>{' '}
108
- via{' '}
109
- <span className="HighlightPath">
110
- {dimensionsInGroup[0].path.join(' ')}
111
- </span>
112
- </h5>
113
- );
114
- const dimensionGroupOptions = dimensionsInGroup.map(dim => {
115
- return {
116
- value: dim.name,
117
- label:
118
- labelize(dim.name.split('.').slice(-1)[0]) +
119
- (dim.properties?.includes('primary_key') ? ' (PK)' : ''),
120
- };
121
- });
122
- //
123
- const cubeDimensions = defaultDimensions.filter(
124
- dim =>
125
- dimensionGroupOptions.filter(x => {
126
- return dim.value === x.value;
127
- }).length > 0,
128
- );
129
- return (
130
- <>
131
- {groupHeader}
132
- <span data-testid={'dimensions-' + dimensionsInGroup[0].node_name}>
133
- <Select
134
- className=""
135
- name={'dimensions-' + dimensionsInGroup[0].node_name}
136
- defaultValue={cubeDimensions}
137
- options={dimensionGroupOptions}
138
- isMulti
139
- isClearable
140
- closeMenuOnSelect={false}
141
- onChange={selected => {
142
- selectedDimensionsByGroup[dimensionsInGroup[0].node_name] =
143
- getValue(selected);
144
- setSelectedDimensionsByGroup(selectedDimensionsByGroup);
145
- setValue(Object.values(selectedDimensionsByGroup).flat(2));
146
- }}
147
- />
148
- </span>
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
+ });