datajunction-ui 0.0.31 → 0.0.34

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,654 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import NodePreAggregationsTab from '../NodePreAggregationsTab';
5
+ import DJClientContext from '../../../providers/djclient';
6
+
7
+ // Mock the CSS import
8
+ jest.mock('../../../../styles/preaggregations.css', () => ({}));
9
+
10
+ // Mock cronstrue - it's imported via require() in the component
11
+ jest.mock('cronstrue', () => ({
12
+ toString: cron => {
13
+ if (cron === '0 0 * * *') return 'At 12:00 AM';
14
+ if (cron === '0 * * * *') return 'Every hour';
15
+ return cron || 'Not scheduled';
16
+ },
17
+ }));
18
+
19
+ // Mock labelize from utils/form
20
+ jest.mock('../../../../utils/form', () => ({
21
+ labelize: str => {
22
+ if (!str) return '';
23
+ // Convert snake_case/SCREAMING_SNAKE to Title Case
24
+ return str
25
+ .toLowerCase()
26
+ .replace(/_/g, ' ')
27
+ .replace(/\b\w/g, c => c.toUpperCase());
28
+ },
29
+ }));
30
+
31
+ const mockNode = {
32
+ name: 'default.orders_fact',
33
+ version: 'v1.0',
34
+ type: 'transform',
35
+ };
36
+
37
+ const mockPreaggs = {
38
+ items: [
39
+ {
40
+ id: 1,
41
+ node_revision_id: 1,
42
+ node_name: 'default.orders_fact',
43
+ node_version: 'v1.0',
44
+ grain_columns: [
45
+ 'default.date_dim.date_id',
46
+ 'default.customer_dim.customer_id',
47
+ ],
48
+ measures: [
49
+ {
50
+ name: 'total_revenue',
51
+ expression: 'revenue',
52
+ aggregation: 'SUM',
53
+ merge: 'SUM',
54
+ rule: { type: 'full' },
55
+ used_by_metrics: [
56
+ { name: 'default.revenue_metric', display_name: 'Revenue' },
57
+ ],
58
+ },
59
+ {
60
+ name: 'order_count',
61
+ expression: '*',
62
+ aggregation: 'COUNT',
63
+ merge: 'SUM',
64
+ rule: { type: 'full' },
65
+ used_by_metrics: [
66
+ { name: 'default.order_count_metric', display_name: 'Order Count' },
67
+ ],
68
+ },
69
+ ],
70
+ sql: 'SELECT date_id, customer_id, SUM(revenue), COUNT(*) FROM orders GROUP BY 1, 2',
71
+ grain_group_hash: 'abc123',
72
+ strategy: 'full',
73
+ schedule: '0 0 * * *',
74
+ lookback_window: null,
75
+ workflow_urls: [
76
+ { label: 'scheduled', url: 'http://scheduler/workflow/123.main' },
77
+ ],
78
+ workflow_status: 'active',
79
+ status: 'active',
80
+ materialized_table_ref: 'analytics.preaggs.orders_fact_abc123',
81
+ max_partition: ['2024', '01', '15'],
82
+ related_metrics: ['default.revenue_metric', 'default.order_count_metric'],
83
+ created_at: '2024-01-01T00:00:00Z',
84
+ updated_at: '2024-01-15T00:00:00Z',
85
+ },
86
+ {
87
+ id: 2,
88
+ node_revision_id: 1,
89
+ node_name: 'default.orders_fact',
90
+ node_version: 'v1.0',
91
+ grain_columns: ['default.product_dim.category'],
92
+ measures: [
93
+ {
94
+ name: 'total_quantity',
95
+ expression: 'quantity',
96
+ aggregation: 'SUM',
97
+ merge: 'SUM',
98
+ rule: { type: 'full' },
99
+ used_by_metrics: null,
100
+ },
101
+ ],
102
+ sql: 'SELECT category, SUM(quantity) FROM orders GROUP BY 1',
103
+ grain_group_hash: 'def456',
104
+ strategy: 'incremental_time',
105
+ schedule: '0 * * * *',
106
+ lookback_window: '3 days',
107
+ workflow_urls: null,
108
+ workflow_status: null,
109
+ status: 'pending',
110
+ materialized_table_ref: null,
111
+ max_partition: null,
112
+ related_metrics: null,
113
+ created_at: '2024-01-10T00:00:00Z',
114
+ updated_at: null,
115
+ },
116
+ ],
117
+ total: 2,
118
+ limit: 50,
119
+ offset: 0,
120
+ };
121
+
122
+ const mockPreaggsWithStale = {
123
+ items: [
124
+ ...mockPreaggs.items,
125
+ {
126
+ id: 3,
127
+ node_revision_id: 0,
128
+ node_name: 'default.orders_fact',
129
+ node_version: 'v0.9', // Stale version
130
+ grain_columns: ['default.date_dim.date_id'],
131
+ measures: [
132
+ {
133
+ name: 'old_revenue',
134
+ expression: 'revenue',
135
+ aggregation: 'SUM',
136
+ merge: 'SUM',
137
+ rule: { type: 'full' },
138
+ used_by_metrics: null,
139
+ },
140
+ ],
141
+ sql: 'SELECT date_id, SUM(revenue) FROM orders GROUP BY 1',
142
+ grain_group_hash: 'old123',
143
+ strategy: 'full',
144
+ schedule: '0 0 * * *',
145
+ workflow_urls: [
146
+ { label: 'scheduled', url: 'http://scheduler/workflow/old.main' },
147
+ ],
148
+ workflow_status: 'active',
149
+ status: 'active',
150
+ materialized_table_ref: 'analytics.preaggs.orders_fact_old',
151
+ max_partition: ['2024', '01', '10'],
152
+ related_metrics: null,
153
+ created_at: '2023-12-01T00:00:00Z',
154
+ updated_at: '2024-01-10T00:00:00Z',
155
+ },
156
+ ],
157
+ total: 3,
158
+ limit: 50,
159
+ offset: 0,
160
+ };
161
+
162
+ const createMockDjClient = (preaggs = mockPreaggs) => ({
163
+ DataJunctionAPI: {
164
+ listPreaggs: jest.fn().mockResolvedValue(preaggs),
165
+ deactivatePreaggWorkflow: jest.fn().mockResolvedValue({ status: 'none' }),
166
+ bulkDeactivatePreaggWorkflows: jest.fn().mockResolvedValue({
167
+ deactivated_count: 1,
168
+ deactivated: [{ id: 3 }],
169
+ }),
170
+ },
171
+ });
172
+
173
+ const renderWithContext = (component, djClient) => {
174
+ return render(
175
+ <MemoryRouter>
176
+ <DJClientContext.Provider value={djClient}>
177
+ {component}
178
+ </DJClientContext.Provider>
179
+ </MemoryRouter>,
180
+ );
181
+ };
182
+
183
+ describe('<NodePreAggregationsTab />', () => {
184
+ beforeEach(() => {
185
+ jest.clearAllMocks();
186
+ });
187
+
188
+ describe('Loading and Empty States', () => {
189
+ it('shows loading state initially', () => {
190
+ const djClient = createMockDjClient();
191
+ // Make the promise never resolve to keep loading state
192
+ djClient.DataJunctionAPI.listPreaggs.mockReturnValue(
193
+ new Promise(() => {}),
194
+ );
195
+
196
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
197
+
198
+ expect(
199
+ screen.getByText('Loading pre-aggregations...'),
200
+ ).toBeInTheDocument();
201
+ });
202
+
203
+ it('shows empty state when no pre-aggregations exist', async () => {
204
+ const djClient = createMockDjClient({
205
+ items: [],
206
+ total: 0,
207
+ limit: 50,
208
+ offset: 0,
209
+ });
210
+
211
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
212
+
213
+ await waitFor(() => {
214
+ expect(
215
+ screen.getByText('No pre-aggregations found for this node.'),
216
+ ).toBeInTheDocument();
217
+ });
218
+ });
219
+
220
+ it('shows error state when API fails', async () => {
221
+ const djClient = createMockDjClient();
222
+ djClient.DataJunctionAPI.listPreaggs.mockResolvedValue({
223
+ _error: true,
224
+ message: 'Failed to fetch',
225
+ });
226
+
227
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
228
+
229
+ await waitFor(() => {
230
+ expect(
231
+ screen.getByText(/Error loading pre-aggregations/),
232
+ ).toBeInTheDocument();
233
+ });
234
+ });
235
+ });
236
+
237
+ describe('Section Headers', () => {
238
+ it('renders "Current Pre-Aggregations" section header with version', async () => {
239
+ const djClient = createMockDjClient();
240
+
241
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
242
+
243
+ await waitFor(() => {
244
+ expect(
245
+ screen.getByText('Current Pre-Aggregations (v1.0)'),
246
+ ).toBeInTheDocument();
247
+ });
248
+ });
249
+
250
+ it('renders "Stale Pre-Aggregations" section when stale preaggs exist', async () => {
251
+ const djClient = createMockDjClient(mockPreaggsWithStale);
252
+
253
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
254
+
255
+ await waitFor(() => {
256
+ expect(
257
+ screen.getByText('Stale Pre-Aggregations (1)'),
258
+ ).toBeInTheDocument();
259
+ });
260
+ });
261
+
262
+ it('does not render stale section when no stale preaggs exist', async () => {
263
+ const djClient = createMockDjClient();
264
+
265
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
266
+
267
+ await waitFor(() => {
268
+ expect(
269
+ screen.getByText('Current Pre-Aggregations (v1.0)'),
270
+ ).toBeInTheDocument();
271
+ });
272
+
273
+ expect(
274
+ screen.queryByText(/Stale Pre-Aggregations/),
275
+ ).not.toBeInTheDocument();
276
+ });
277
+ });
278
+
279
+ describe('Pre-agg Row Header', () => {
280
+ it('renders grain columns as chips', async () => {
281
+ const djClient = createMockDjClient();
282
+
283
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
284
+
285
+ await waitFor(() => {
286
+ // Should show short names from grain columns
287
+ expect(screen.getByText('date_id')).toBeInTheDocument();
288
+ expect(screen.getByText('customer_id')).toBeInTheDocument();
289
+ });
290
+ });
291
+
292
+ it('renders measure count', async () => {
293
+ const djClient = createMockDjClient();
294
+
295
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
296
+
297
+ await waitFor(() => {
298
+ expect(screen.getByText('2 measures')).toBeInTheDocument();
299
+ expect(screen.getByText('1 measure')).toBeInTheDocument();
300
+ });
301
+ });
302
+
303
+ it('renders metric count badge when related metrics exist', async () => {
304
+ const djClient = createMockDjClient();
305
+
306
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
307
+
308
+ await waitFor(() => {
309
+ expect(screen.getByText('2 metrics')).toBeInTheDocument();
310
+ });
311
+ });
312
+
313
+ it('renders status badges', async () => {
314
+ const djClient = createMockDjClient();
315
+
316
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
317
+
318
+ await waitFor(() => {
319
+ expect(screen.getByText('Active')).toBeInTheDocument();
320
+ expect(screen.getByText('Pending')).toBeInTheDocument();
321
+ });
322
+ });
323
+
324
+ it('renders schedule in human-readable format', async () => {
325
+ const djClient = createMockDjClient();
326
+
327
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
328
+
329
+ await waitFor(() => {
330
+ // Schedule appears in both header and config section, so use getAllByText
331
+ const scheduleElements = screen.getAllByText(/at 12:00 am/i);
332
+ expect(scheduleElements.length).toBeGreaterThan(0);
333
+ });
334
+ });
335
+ });
336
+
337
+ describe('Expanded Details', () => {
338
+ it('expands first pre-agg by default', async () => {
339
+ const djClient = createMockDjClient();
340
+
341
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
342
+
343
+ await waitFor(() => {
344
+ // Config section should be visible for the first expanded preagg
345
+ expect(screen.getByText('Config')).toBeInTheDocument();
346
+ expect(screen.getByText('Grain')).toBeInTheDocument();
347
+ expect(screen.getByText('Measures')).toBeInTheDocument();
348
+ });
349
+ });
350
+
351
+ it('shows Config section with strategy and schedule', async () => {
352
+ const djClient = createMockDjClient();
353
+
354
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
355
+
356
+ await waitFor(() => {
357
+ expect(screen.getByText('Strategy')).toBeInTheDocument();
358
+ expect(screen.getByText('Full')).toBeInTheDocument();
359
+ expect(screen.getByText('Schedule')).toBeInTheDocument();
360
+ });
361
+ });
362
+
363
+ it('shows Grain section with dimension links', async () => {
364
+ const djClient = createMockDjClient();
365
+
366
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
367
+
368
+ await waitFor(() => {
369
+ // Full grain column names should appear in expanded section
370
+ const grainBadges = screen.getAllByText(
371
+ /default\.(date_dim|customer_dim)\./,
372
+ );
373
+ expect(grainBadges.length).toBeGreaterThan(0);
374
+ });
375
+ });
376
+
377
+ it('shows Measures table with aggregation and merge info', async () => {
378
+ const djClient = createMockDjClient();
379
+
380
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
381
+
382
+ await waitFor(() => {
383
+ expect(screen.getByText('total_revenue')).toBeInTheDocument();
384
+ expect(screen.getByText('SUM(revenue)')).toBeInTheDocument();
385
+ });
386
+ });
387
+
388
+ it('shows metrics that use each measure', async () => {
389
+ const djClient = createMockDjClient();
390
+
391
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
392
+
393
+ await waitFor(() => {
394
+ // Display names should appear
395
+ expect(screen.getByText('Revenue')).toBeInTheDocument();
396
+ expect(screen.getByText('Order Count')).toBeInTheDocument();
397
+ });
398
+ });
399
+
400
+ it('toggles expansion when clicking row header', async () => {
401
+ // Use single preagg to simplify test
402
+ const singlePreagg = {
403
+ items: [mockPreaggs.items[0]],
404
+ total: 1,
405
+ limit: 50,
406
+ offset: 0,
407
+ };
408
+ const djClient = createMockDjClient(singlePreagg);
409
+
410
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
411
+
412
+ // Wait for initial render with expanded state
413
+ await waitFor(() => {
414
+ expect(screen.getByText('Config')).toBeInTheDocument();
415
+ });
416
+
417
+ // Find and click the row header to collapse it
418
+ const measureText = screen.getByText('2 measures');
419
+ fireEvent.click(measureText.closest('.preagg-row-header'));
420
+
421
+ // After collapse, Config should no longer be visible
422
+ await waitFor(
423
+ () => {
424
+ expect(screen.queryByText('Config')).not.toBeInTheDocument();
425
+ },
426
+ { timeout: 2000 },
427
+ );
428
+ });
429
+ });
430
+
431
+ describe('Workflow Actions', () => {
432
+ it('renders workflow button with capitalized label', async () => {
433
+ const djClient = createMockDjClient();
434
+
435
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
436
+
437
+ await waitFor(() => {
438
+ const scheduledBtn = screen.getByText('Scheduled');
439
+ expect(scheduledBtn).toBeInTheDocument();
440
+ expect(scheduledBtn.closest('a')).toHaveAttribute(
441
+ 'href',
442
+ 'http://scheduler/workflow/123.main',
443
+ );
444
+ });
445
+ });
446
+
447
+ it('renders deactivate button for active workflows', async () => {
448
+ const djClient = createMockDjClient();
449
+
450
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
451
+
452
+ await waitFor(() => {
453
+ expect(screen.getByText('Deactivate')).toBeInTheDocument();
454
+ });
455
+ });
456
+
457
+ it('calls deactivatePreaggWorkflow when deactivate is clicked', async () => {
458
+ const djClient = createMockDjClient();
459
+ window.confirm = jest.fn(() => true);
460
+
461
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
462
+
463
+ await waitFor(
464
+ () => {
465
+ expect(screen.getByText('Deactivate')).toBeInTheDocument();
466
+ },
467
+ { timeout: 2000 },
468
+ );
469
+
470
+ fireEvent.click(screen.getByText('Deactivate'));
471
+
472
+ await waitFor(
473
+ () => {
474
+ expect(
475
+ djClient.DataJunctionAPI.deactivatePreaggWorkflow,
476
+ ).toHaveBeenCalledWith(1);
477
+ },
478
+ { timeout: 2000 },
479
+ );
480
+ });
481
+ });
482
+
483
+ describe('Stale Pre-aggregations', () => {
484
+ it('shows stale version warning in row header', async () => {
485
+ const djClient = createMockDjClient(mockPreaggsWithStale);
486
+
487
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
488
+
489
+ await waitFor(() => {
490
+ expect(screen.getByText('was v0.9')).toBeInTheDocument();
491
+ });
492
+ });
493
+
494
+ it('shows "Deactivate All Stale" button when active stale workflows exist', async () => {
495
+ const djClient = createMockDjClient(mockPreaggsWithStale);
496
+
497
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
498
+
499
+ await waitFor(() => {
500
+ expect(screen.getByText('Deactivate All Stale')).toBeInTheDocument();
501
+ });
502
+ });
503
+
504
+ it('calls bulkDeactivatePreaggWorkflows when "Deactivate All Stale" is clicked', async () => {
505
+ const djClient = createMockDjClient(mockPreaggsWithStale);
506
+ window.confirm = jest.fn(() => true);
507
+
508
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
509
+
510
+ await waitFor(
511
+ () => {
512
+ expect(screen.getByText('Deactivate All Stale')).toBeInTheDocument();
513
+ },
514
+ { timeout: 2000 },
515
+ );
516
+
517
+ fireEvent.click(screen.getByText('Deactivate All Stale'));
518
+
519
+ await waitFor(
520
+ () => {
521
+ expect(
522
+ djClient.DataJunctionAPI.bulkDeactivatePreaggWorkflows,
523
+ ).toHaveBeenCalledWith('default.orders_fact', true);
524
+ },
525
+ { timeout: 2000 },
526
+ );
527
+ });
528
+ });
529
+
530
+ describe('Grain Truncation', () => {
531
+ it('shows "+N more" button when grain has more than MAX_VISIBLE_GRAIN columns', async () => {
532
+ const manyGrainPreaggs = {
533
+ items: [
534
+ {
535
+ ...mockPreaggs.items[0],
536
+ grain_columns: [
537
+ 'default.dim1.col1',
538
+ 'default.dim2.col2',
539
+ 'default.dim3.col3',
540
+ 'default.dim4.col4',
541
+ 'default.dim5.col5',
542
+ 'default.dim6.col6',
543
+ 'default.dim7.col7',
544
+ 'default.dim8.col8',
545
+ 'default.dim9.col9',
546
+ 'default.dim10.col10',
547
+ 'default.dim11.col11',
548
+ 'default.dim12.col12',
549
+ ],
550
+ },
551
+ ],
552
+ total: 1,
553
+ limit: 50,
554
+ offset: 0,
555
+ };
556
+ const djClient = createMockDjClient(manyGrainPreaggs);
557
+
558
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
559
+
560
+ await waitFor(() => {
561
+ // Should show "+N more" since there are 12 columns and MAX_VISIBLE_GRAIN is 10
562
+ expect(screen.getByText('+2 more')).toBeInTheDocument();
563
+ });
564
+ });
565
+
566
+ it('shows "Show less" after expanding grain list', async () => {
567
+ const manyGrainPreaggs = {
568
+ items: [
569
+ {
570
+ ...mockPreaggs.items[0],
571
+ grain_columns: [
572
+ 'default.dim1.col1',
573
+ 'default.dim2.col2',
574
+ 'default.dim3.col3',
575
+ 'default.dim4.col4',
576
+ 'default.dim5.col5',
577
+ 'default.dim6.col6',
578
+ 'default.dim7.col7',
579
+ 'default.dim8.col8',
580
+ 'default.dim9.col9',
581
+ 'default.dim10.col10',
582
+ 'default.dim11.col11',
583
+ 'default.dim12.col12',
584
+ ],
585
+ },
586
+ ],
587
+ total: 1,
588
+ limit: 50,
589
+ offset: 0,
590
+ };
591
+ const djClient = createMockDjClient(manyGrainPreaggs);
592
+
593
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
594
+
595
+ await waitFor(
596
+ () => {
597
+ expect(screen.getByText('+2 more')).toBeInTheDocument();
598
+ },
599
+ { timeout: 2000 },
600
+ );
601
+
602
+ // Click to expand
603
+ fireEvent.click(screen.getByText('+2 more'));
604
+
605
+ await waitFor(
606
+ () => {
607
+ expect(screen.getByText('Show less')).toBeInTheDocument();
608
+ // All columns should now be visible
609
+ expect(screen.getByText('default.dim12.col12')).toBeInTheDocument();
610
+ },
611
+ { timeout: 2000 },
612
+ );
613
+ });
614
+ });
615
+
616
+ describe('API Integration', () => {
617
+ it('calls listPreaggs with include_stale=true', async () => {
618
+ const djClient = createMockDjClient();
619
+
620
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
621
+
622
+ await waitFor(() => {
623
+ expect(djClient.DataJunctionAPI.listPreaggs).toHaveBeenCalledWith({
624
+ node_name: 'default.orders_fact',
625
+ include_stale: true,
626
+ });
627
+ });
628
+ });
629
+
630
+ it('refreshes list after deactivate action', async () => {
631
+ const djClient = createMockDjClient();
632
+ window.confirm = jest.fn(() => true);
633
+
634
+ renderWithContext(<NodePreAggregationsTab node={mockNode} />, djClient);
635
+
636
+ await waitFor(
637
+ () => {
638
+ expect(screen.getByText('Deactivate')).toBeInTheDocument();
639
+ },
640
+ { timeout: 2000 },
641
+ );
642
+
643
+ fireEvent.click(screen.getByText('Deactivate'));
644
+
645
+ await waitFor(
646
+ () => {
647
+ // Should be called twice: initial load + refresh after deactivate
648
+ expect(djClient.DataJunctionAPI.listPreaggs).toHaveBeenCalledTimes(2);
649
+ },
650
+ { timeout: 2000 },
651
+ );
652
+ });
653
+ });
654
+ });
@@ -11,6 +11,7 @@ import NotebookDownload from './NotebookDownload';
11
11
  import DJClientContext from '../../providers/djclient';
12
12
  import NodeValidateTab from './NodeValidateTab';
13
13
  import NodeMaterializationTab from './NodeMaterializationTab';
14
+ import NodePreAggregationsTab from './NodePreAggregationsTab';
14
15
  import ClientCodePopover from './ClientCodePopover';
15
16
  import WatchButton from './WatchNodeButton';
16
17
  import NodesWithDimension from './NodesWithDimension';
@@ -131,7 +132,14 @@ export function NodePage() {
131
132
  tabToDisplay = <NodeValidateTab node={node} djClient={djClient} />;
132
133
  break;
133
134
  case 'materializations':
134
- tabToDisplay = <NodeMaterializationTab node={node} djClient={djClient} />;
135
+ // Cube nodes use cube-specific materialization tab
136
+ // Other nodes (transform, metric, dimension) use pre-aggregations tab
137
+ tabToDisplay =
138
+ node?.type === 'cube' ? (
139
+ <NodeMaterializationTab node={node} djClient={djClient} />
140
+ ) : (
141
+ <NodePreAggregationsTab node={node} />
142
+ );
135
143
  break;
136
144
  case 'linked':
137
145
  tabToDisplay = <NodesWithDimension node={node} djClient={djClient} />;