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.
@@ -0,0 +1,489 @@
1
+ /* CubeBuilderPage - Query Planner inspired styling */
2
+
3
+ /* ================================
4
+ Page Container - viewport-constrained
5
+ ================================ */
6
+ .cube-builder {
7
+ padding: 16px 24px;
8
+ background: var(--planner-bg, #f8fafc);
9
+ height: calc(100vh - 150px);
10
+ display: flex;
11
+ flex-direction: column;
12
+ font-family: var(
13
+ --font-body,
14
+ 'Inter',
15
+ -apple-system,
16
+ BlinkMacSystemFont,
17
+ sans-serif
18
+ );
19
+ color: var(--planner-text, #1e293b);
20
+ font-size: 13px;
21
+ box-sizing: border-box;
22
+ }
23
+
24
+ /* Header */
25
+ .cube-builder-header {
26
+ margin-bottom: 16px;
27
+ flex-shrink: 0;
28
+ }
29
+
30
+ .cube-builder-header h2 {
31
+ margin: 0;
32
+ font-size: 18px;
33
+ font-weight: 600;
34
+ }
35
+
36
+ /* ================================
37
+ Two-Column Layout
38
+ ================================ */
39
+ .cube-builder-layout {
40
+ display: flex;
41
+ gap: 16px;
42
+ flex: 1;
43
+ min-height: 0;
44
+ }
45
+
46
+ /* Left: Main form area - scrolls internally */
47
+ .cube-builder-main {
48
+ flex: 1;
49
+ min-width: 0;
50
+ background: var(--planner-surface, #ffffff);
51
+ border: 1px solid var(--planner-border, #e2e8f0);
52
+ overflow-y: auto;
53
+ }
54
+
55
+ /* Right: Sidebar - fixed height matching main column */
56
+ .cube-builder-sidebar {
57
+ width: 480px;
58
+ flex-shrink: 0;
59
+ display: flex;
60
+ flex-direction: column;
61
+ min-height: 0;
62
+ }
63
+
64
+ .cube-builder-sidebar .cube-preview-panel {
65
+ flex: 1;
66
+ min-height: 0;
67
+ display: flex;
68
+ flex-direction: column;
69
+ }
70
+
71
+ .cube-builder-sidebar .cube-preview-panel .preview-sql-container {
72
+ flex: 1;
73
+ min-height: 100px;
74
+ overflow: auto;
75
+ }
76
+
77
+ /* ================================
78
+ Section styling (like Query Planner panels)
79
+ ================================ */
80
+ .cube-form-section {
81
+ border-bottom: 1px solid var(--planner-border, #e2e8f0);
82
+ }
83
+
84
+ .cube-form-section:last-child {
85
+ border-bottom: none;
86
+ }
87
+
88
+ .cube-form-section-header {
89
+ display: flex;
90
+ justify-content: space-between;
91
+ align-items: center;
92
+ padding: 12px 16px;
93
+ background: var(--planner-surface, #ffffff);
94
+ border-bottom: 1px solid var(--planner-border, #e2e8f0);
95
+ }
96
+
97
+ .cube-form-section-header h3 {
98
+ margin: 0;
99
+ font-size: 11px;
100
+ font-weight: 700;
101
+ text-transform: uppercase;
102
+ letter-spacing: 0.5px;
103
+ color: var(--planner-text, #1e293b);
104
+ }
105
+
106
+ .cube-form-section-body {
107
+ padding: 12px 16px;
108
+ }
109
+
110
+ /* ================================
111
+ Form Fields
112
+ ================================ */
113
+ .cube-field {
114
+ margin-bottom: 16px;
115
+ }
116
+
117
+ .cube-field:last-child {
118
+ margin-bottom: 0;
119
+ }
120
+
121
+ /* Two-column row */
122
+ .cube-field-row {
123
+ display: flex;
124
+ gap: 16px;
125
+ margin-bottom: 16px;
126
+ }
127
+
128
+ .cube-field-row:last-child {
129
+ margin-bottom: 0;
130
+ }
131
+
132
+ .cube-field-row .cube-field {
133
+ margin-bottom: 0;
134
+ }
135
+
136
+ .cube-field-grow {
137
+ flex: 1;
138
+ min-width: 0;
139
+ }
140
+
141
+ .cube-field-small {
142
+ width: 140px;
143
+ flex-shrink: 0;
144
+ }
145
+
146
+ .cube-field-half {
147
+ flex: 1;
148
+ min-width: 0;
149
+ }
150
+
151
+ .cube-field-label {
152
+ display: block;
153
+ font-size: 11px;
154
+ font-weight: 600;
155
+ text-transform: uppercase;
156
+ letter-spacing: 0.3px;
157
+ color: var(--planner-text-muted, #64748b);
158
+ margin-bottom: 6px;
159
+ }
160
+
161
+ .cube-field-input,
162
+ .cube-builder input[type='text'],
163
+ .cube-builder textarea,
164
+ .cube-builder select {
165
+ width: 100%;
166
+ height: 36px;
167
+ padding: 8px 12px;
168
+ font-size: 13px;
169
+ font-family: inherit;
170
+ color: var(--planner-text, #1e293b);
171
+ background: var(--planner-surface, #ffffff);
172
+ border: 1px solid var(--planner-border, #e2e8f0);
173
+ border-radius: var(--radius-sm, 4px);
174
+ box-sizing: border-box;
175
+ transition: border-color 0.15s ease;
176
+ }
177
+
178
+ .cube-builder input[type='text']:focus,
179
+ .cube-builder textarea:focus,
180
+ .cube-builder select:focus {
181
+ outline: none;
182
+ border-color: var(--accent-primary, #3b82f6);
183
+ }
184
+
185
+ .cube-builder textarea {
186
+ height: auto;
187
+ min-height: 36px;
188
+ max-height: 80px;
189
+ resize: vertical;
190
+ }
191
+
192
+ .cube-field-static {
193
+ height: 36px;
194
+ padding: 8px 12px;
195
+ font-size: 13px;
196
+ color: var(--planner-text, #1e293b);
197
+ background: var(--planner-surface-hover, #f8fafc);
198
+ border: 1px solid var(--planner-border, #e2e8f0);
199
+ border-radius: var(--radius-sm, 4px);
200
+ box-sizing: border-box;
201
+ }
202
+
203
+ /* Override legacy form field styles */
204
+ .cube-builder .NodeCreationInput,
205
+ .cube-builder .CubeCreationInput,
206
+ .cube-builder .NodeNameInput,
207
+ .cube-builder .DisplayNameInput,
208
+ .cube-builder .DescriptionInput,
209
+ .cube-builder .NamespaceInput,
210
+ .cube-builder .FullNameInput,
211
+ .cube-builder .TagsInput,
212
+ .cube-builder .cube-field-half > div,
213
+ .cube-builder .cube-field-row .TagsInput,
214
+ .cube-builder .cube-field-row .NodeCreationInput {
215
+ display: block !important;
216
+ width: 100% !important;
217
+ margin: 0 !important;
218
+ padding: 0 !important;
219
+ }
220
+
221
+ .cube-builder .cube-form-section-body > .NodeCreationInput,
222
+ .cube-builder .cube-form-section-body > .NamespaceInput {
223
+ margin-bottom: 16px !important;
224
+ }
225
+
226
+ .cube-builder .NodeCreationInput label,
227
+ .cube-builder .CubeCreationInput label,
228
+ .cube-builder .NamespaceInput label,
229
+ .cube-builder .FullNameInput label,
230
+ .cube-builder .TagsInput label,
231
+ .cube-builder .cube-field-half label {
232
+ display: block !important;
233
+ font-size: 11px !important;
234
+ font-weight: 600 !important;
235
+ text-transform: uppercase !important;
236
+ letter-spacing: 0.3px !important;
237
+ color: var(--planner-text-muted, #64748b) !important;
238
+ margin-bottom: 6px !important;
239
+ padding: 0 !important;
240
+ }
241
+
242
+ /* ================================
243
+ Settings Panel (in sidebar)
244
+ ================================ */
245
+ .cube-settings {
246
+ margin-top: 12px;
247
+ flex-shrink: 0;
248
+ }
249
+
250
+ .save-cube-btn {
251
+ width: 100%;
252
+ padding: 10px 16px;
253
+ font-size: 13px;
254
+ font-weight: 600;
255
+ font-family: inherit;
256
+ background: var(--accent-primary, #3b82f6);
257
+ color: white;
258
+ border: none;
259
+ border-radius: var(--radius-sm, 4px);
260
+ cursor: pointer;
261
+ transition: background 0.15s ease;
262
+ }
263
+
264
+ .save-cube-btn:hover {
265
+ background: #2563eb;
266
+ }
267
+
268
+ .save-cube-btn:disabled {
269
+ opacity: 0.6;
270
+ cursor: not-allowed;
271
+ }
272
+
273
+ /* Success state - briefly shown after save */
274
+ .save-cube-btn.save-cube-btn--saved,
275
+ .save-cube-btn.save-cube-btn--saved:disabled {
276
+ background: var(--accent-success, #059669);
277
+ opacity: 1;
278
+ cursor: default;
279
+ }
280
+
281
+ /* Loading state with spinner */
282
+ .save-cube-btn.save-cube-btn--loading,
283
+ .save-cube-btn.save-cube-btn--loading:disabled {
284
+ opacity: 1;
285
+ display: inline-flex;
286
+ align-items: center;
287
+ justify-content: center;
288
+ gap: 8px;
289
+ cursor: wait;
290
+ }
291
+
292
+ .save-spinner {
293
+ width: 14px;
294
+ height: 14px;
295
+ border: 2px solid rgba(255, 255, 255, 0.3);
296
+ border-top-color: #ffffff;
297
+ border-radius: 50%;
298
+ animation: save-spinner-spin 0.6s linear infinite;
299
+ display: inline-block;
300
+ }
301
+
302
+ @keyframes save-spinner-spin {
303
+ to {
304
+ transform: rotate(360deg);
305
+ }
306
+ }
307
+
308
+ /* Error message shown above the save button */
309
+ .save-error-message {
310
+ padding: 8px 12px;
311
+ margin-bottom: 8px;
312
+ background: rgba(220, 38, 38, 0.08);
313
+ color: var(--accent-error, #dc2626);
314
+ border: 1px solid rgba(220, 38, 38, 0.2);
315
+ border-radius: var(--radius-sm, 4px);
316
+ font-size: 12px;
317
+ line-height: 1.4;
318
+ }
319
+
320
+ /* ================================
321
+ Preview Panel (SQL)
322
+ ================================ */
323
+ .cube-preview-panel {
324
+ background: var(--planner-surface, #ffffff);
325
+ border: 1px solid var(--planner-border, #e2e8f0);
326
+ overflow: hidden;
327
+ }
328
+
329
+ /* Flatten the scan estimate banner so it doesn't look like a nested panel */
330
+ .cube-preview-panel .scan-estimate-banner {
331
+ margin: 0 !important;
332
+ border-radius: 0 !important;
333
+ border: none !important;
334
+ border-bottom: 1px solid var(--planner-border, #e2e8f0) !important;
335
+ padding: 10px 16px !important;
336
+ }
337
+
338
+ .preview-section-header {
339
+ display: flex;
340
+ align-items: center;
341
+ gap: 8px;
342
+ padding: 12px 16px;
343
+ border-bottom: 1px solid var(--planner-border, #e2e8f0);
344
+ }
345
+
346
+ .preview-section-icon {
347
+ font-size: 12px;
348
+ color: var(--planner-text-muted, #64748b);
349
+ }
350
+
351
+ .preview-section-title {
352
+ margin: 0;
353
+ font-size: 11px;
354
+ font-weight: 700;
355
+ text-transform: uppercase;
356
+ letter-spacing: 0.5px;
357
+ color: var(--planner-text, #1e293b);
358
+ }
359
+
360
+ .preview-sql-container {
361
+ max-height: 600px;
362
+ overflow: auto;
363
+ background: var(--planner-surface-hover, #f8fafc);
364
+ padding: 16px;
365
+ }
366
+
367
+ .preview-sql-container pre,
368
+ .preview-sql-container code {
369
+ border-radius: 0 !important;
370
+ background: transparent !important;
371
+ padding: 0 !important;
372
+ margin: 0 !important;
373
+ }
374
+
375
+ .preview-loading,
376
+ .preview-error,
377
+ .preview-empty {
378
+ font-size: 12px;
379
+ color: var(--planner-text-muted, #64748b);
380
+ }
381
+
382
+ .preview-error {
383
+ color: var(--accent-error, #dc2626);
384
+ }
385
+
386
+ /* ================================
387
+ Dimensions (hop groups)
388
+ ================================ */
389
+ .hop-section {
390
+ margin-bottom: 20px;
391
+ }
392
+
393
+ .hop-section:last-child {
394
+ margin-bottom: 0;
395
+ }
396
+
397
+ .hop-header {
398
+ display: flex;
399
+ align-items: center;
400
+ padding: 8px 0;
401
+ border-bottom: 1px solid var(--planner-border, #e2e8f0);
402
+ margin-bottom: 12px;
403
+ }
404
+
405
+ .hop-title {
406
+ font-size: 11px;
407
+ font-weight: 700;
408
+ text-transform: uppercase;
409
+ letter-spacing: 0.3px;
410
+ color: var(--planner-text, #1e293b);
411
+ }
412
+
413
+ .hop-count {
414
+ margin-left: 8px;
415
+ font-size: 11px;
416
+ color: var(--planner-text-dim, #94a3b8);
417
+ }
418
+
419
+ .dimension-groups {
420
+ padding-left: 16px;
421
+ }
422
+
423
+ .dimension-group {
424
+ margin-bottom: 12px;
425
+ }
426
+
427
+ .dimension-group-header {
428
+ font-size: 13px;
429
+ font-weight: normal;
430
+ margin: 0 0 6px 0;
431
+ color: var(--planner-text, #1e293b);
432
+ }
433
+
434
+ .dimension-group-header a {
435
+ color: var(--accent-primary, #3b82f6);
436
+ text-decoration: none;
437
+ }
438
+
439
+ .dimension-group-header a:hover {
440
+ text-decoration: underline;
441
+ }
442
+
443
+ .dimension-group-header .via-text {
444
+ color: var(--planner-text-muted, #64748b);
445
+ }
446
+
447
+ /* ================================
448
+ React-Select styling (matching Query Planner chips exactly)
449
+ ================================ */
450
+ .cube-builder .cube-field-half > div > div {
451
+ font-size: 13px;
452
+ }
453
+
454
+ /* Multi-value chips - simple spacing */
455
+ .cube-builder div[class*='multiValue'] {
456
+ margin: 2px 4px 2px 0 !important;
457
+ border-radius: 3px !important;
458
+ }
459
+
460
+ .cube-builder div[class*='multiValueLabel'] {
461
+ font-size: 10px !important;
462
+ font-weight: 500 !important;
463
+ }
464
+
465
+ .cube-builder div[class*='multiValueRemove'] svg {
466
+ width: 12px !important;
467
+ height: 12px !important;
468
+ }
469
+
470
+ /* Reduce Input wrapper height to match chip height */
471
+ .cube-builder div[class*='-Input'],
472
+ .cube-builder div[class*='Input'] {
473
+ margin: 0 !important;
474
+ padding: 0 !important;
475
+ }
476
+
477
+ .cube-builder div[class*='Input'] > div,
478
+ .cube-builder div[class*='Input'] > input {
479
+ height: 20px !important;
480
+ line-height: 20px !important;
481
+ margin: 0 !important;
482
+ padding: 0 !important;
483
+ font-size: 13px !important;
484
+ }
485
+
486
+ /* Target the grid sizer inside Input that forces a minimum height */
487
+ .cube-builder div[class*='Input'] [data-value] {
488
+ height: 20px !important;
489
+ }
@@ -169,7 +169,7 @@ export default function NodeInfoTab({ node }) {
169
169
  return (
170
170
  <div
171
171
  className="button-3 cube-element"
172
- key={cubeElem.name}
172
+ key={cubeElem.name + (cubeElem.role || '')}
173
173
  role="cell"
174
174
  aria-label="CubeElement"
175
175
  aria-hidden="false"
@@ -179,6 +179,17 @@ export default function NodeInfoTab({ node }) {
179
179
  ? labelize(cubeElem.node_name.split('.').slice(-1)[0]) + ' → '
180
180
  : ''}
181
181
  {cubeElem.display_name}
182
+ {cubeElem.role && (
183
+ <span
184
+ style={{
185
+ marginLeft: '4px',
186
+ fontSize: '85%',
187
+ color: '#6c757d',
188
+ }}
189
+ >
190
+ [{cubeElem.role}]
191
+ </span>
192
+ )}
182
193
  </a>
183
194
  <span
184
195
  className={`badge node_type__${
@@ -573,10 +573,12 @@ export const DataJunctionAPI = {
573
573
  mode
574
574
  cubeMetrics {
575
575
  name
576
+ displayName
576
577
  }
577
578
  cubeDimensions {
578
579
  name
579
580
  attribute
581
+ role
580
582
  properties
581
583
  }
582
584
  }
@@ -1048,6 +1050,73 @@ export const DataJunctionAPI = {
1048
1050
  ).json();
1049
1051
  },
1050
1052
 
1053
+ getMetricsInfo: async function (names) {
1054
+ if (!names || names.length === 0) return [];
1055
+ const gqlQuery = `
1056
+ query GetMetricsInfo($names: [String!]!) {
1057
+ findNodes(names: $names) {
1058
+ name
1059
+ current {
1060
+ displayName
1061
+ }
1062
+ gitInfo {
1063
+ branch
1064
+ isDefaultBranch
1065
+ }
1066
+ }
1067
+ }
1068
+ `;
1069
+ const response = await fetch(DJ_GQL, {
1070
+ method: 'POST',
1071
+ headers: { 'Content-Type': 'application/json' },
1072
+ credentials: 'include',
1073
+ body: JSON.stringify({
1074
+ query: gqlQuery,
1075
+ variables: { names },
1076
+ }),
1077
+ });
1078
+ const result = await response.json();
1079
+ return (result?.data?.findNodes || []).map(node => ({
1080
+ value: node.name,
1081
+ label: node.current?.displayName || node.name,
1082
+ name: node.name,
1083
+ gitInfo: node.gitInfo,
1084
+ }));
1085
+ },
1086
+
1087
+ searchMetrics: async function (query, limit = 50) {
1088
+ const gqlQuery = `
1089
+ query SearchMetrics($q: String!, $limit: Int!) {
1090
+ findNodes(search: $q, nodeTypes: [METRIC], limit: $limit) {
1091
+ name
1092
+ current {
1093
+ displayName
1094
+ }
1095
+ gitInfo {
1096
+ branch
1097
+ isDefaultBranch
1098
+ }
1099
+ }
1100
+ }
1101
+ `;
1102
+ const response = await fetch(DJ_GQL, {
1103
+ method: 'POST',
1104
+ headers: { 'Content-Type': 'application/json' },
1105
+ credentials: 'include',
1106
+ body: JSON.stringify({
1107
+ query: gqlQuery,
1108
+ variables: { q: query, limit },
1109
+ }),
1110
+ });
1111
+ const result = await response.json();
1112
+ return (result?.data?.findNodes || []).map(node => ({
1113
+ value: node.name,
1114
+ label: node.current?.displayName || node.name,
1115
+ name: node.name,
1116
+ gitInfo: node.gitInfo,
1117
+ }));
1118
+ },
1119
+
1051
1120
  commonDimensions: async function (metrics) {
1052
1121
  const metricsQuery = '?' + metrics.map(m => `metric=${m}`).join('&');
1053
1122
  return await (
@@ -2079,6 +2079,106 @@ describe('DataJunctionAPI', () => {
2079
2079
  expect(result).toBeNull();
2080
2080
  });
2081
2081
 
2082
+ // searchMetrics — wraps findNodes(METRIC) and shapes results for react-select
2083
+ it('calls searchMetrics correctly', async () => {
2084
+ fetch.mockResponseOnce(
2085
+ JSON.stringify({
2086
+ data: {
2087
+ findNodes: [
2088
+ {
2089
+ name: 'default.revenue',
2090
+ current: { displayName: 'Revenue' },
2091
+ gitInfo: { branch: 'main', isDefaultBranch: true },
2092
+ },
2093
+ {
2094
+ name: 'default.orders',
2095
+ current: { displayName: null },
2096
+ gitInfo: null,
2097
+ },
2098
+ ],
2099
+ },
2100
+ }),
2101
+ );
2102
+
2103
+ const result = await DataJunctionAPI.searchMetrics('rev', 25);
2104
+ expect(fetch).toHaveBeenCalledWith(
2105
+ 'http://localhost:8000/graphql',
2106
+ expect.objectContaining({ method: 'POST', credentials: 'include' }),
2107
+ );
2108
+ const sentBody = JSON.parse(fetch.mock.calls[0][1].body);
2109
+ expect(sentBody.variables).toEqual({ q: 'rev', limit: 25 });
2110
+ expect(result).toEqual([
2111
+ {
2112
+ value: 'default.revenue',
2113
+ label: 'Revenue',
2114
+ name: 'default.revenue',
2115
+ gitInfo: { branch: 'main', isDefaultBranch: true },
2116
+ },
2117
+ // Falls back to name when displayName is missing.
2118
+ {
2119
+ value: 'default.orders',
2120
+ label: 'default.orders',
2121
+ name: 'default.orders',
2122
+ gitInfo: null,
2123
+ },
2124
+ ]);
2125
+ });
2126
+
2127
+ it('searchMetrics returns [] when the GraphQL response has no data', async () => {
2128
+ fetch.mockResponseOnce(JSON.stringify({}));
2129
+ const result = await DataJunctionAPI.searchMetrics('whatever');
2130
+ expect(result).toEqual([]);
2131
+ });
2132
+
2133
+ it('searchMetrics defaults limit to 50', async () => {
2134
+ fetch.mockResponseOnce(JSON.stringify({ data: { findNodes: [] } }));
2135
+ await DataJunctionAPI.searchMetrics('rev');
2136
+ const sentBody = JSON.parse(fetch.mock.calls[0][1].body);
2137
+ expect(sentBody.variables.limit).toBe(50);
2138
+ });
2139
+
2140
+ // getMetricsInfo — bulk-fetches existing metric metadata, used to populate
2141
+ // branch badges on previously-selected chips when editing a cube.
2142
+ it('calls getMetricsInfo correctly and shapes the response', async () => {
2143
+ fetch.mockResponseOnce(
2144
+ JSON.stringify({
2145
+ data: {
2146
+ findNodes: [
2147
+ {
2148
+ name: 'default.revenue',
2149
+ current: { displayName: 'Revenue' },
2150
+ gitInfo: { branch: 'main', isDefaultBranch: true },
2151
+ },
2152
+ ],
2153
+ },
2154
+ }),
2155
+ );
2156
+
2157
+ const result = await DataJunctionAPI.getMetricsInfo(['default.revenue']);
2158
+ const sentBody = JSON.parse(fetch.mock.calls[0][1].body);
2159
+ expect(sentBody.variables).toEqual({ names: ['default.revenue'] });
2160
+ expect(result).toEqual([
2161
+ {
2162
+ value: 'default.revenue',
2163
+ label: 'Revenue',
2164
+ name: 'default.revenue',
2165
+ gitInfo: { branch: 'main', isDefaultBranch: true },
2166
+ },
2167
+ ]);
2168
+ });
2169
+
2170
+ it('getMetricsInfo short-circuits on empty/null input without hitting fetch', async () => {
2171
+ expect(await DataJunctionAPI.getMetricsInfo([])).toEqual([]);
2172
+ expect(await DataJunctionAPI.getMetricsInfo(null)).toEqual([]);
2173
+ expect(fetch).not.toHaveBeenCalled();
2174
+ });
2175
+
2176
+ it('getMetricsInfo returns [] when GraphQL has no data', async () => {
2177
+ fetch.mockResponseOnce(JSON.stringify({}));
2178
+ const result = await DataJunctionAPI.getMetricsInfo(['anything']);
2179
+ expect(result).toEqual([]);
2180
+ });
2181
+
2082
2182
  // Test logout (lines 225-230)
2083
2183
  it('calls logout correctly', async () => {
2084
2184
  fetch.mockResponseOnce('', { status: 200 });