datajunction-ui 0.0.1-a112 → 0.0.1-a113.dev0

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.
@@ -1,25 +1,100 @@
1
- import { useEffect, useState } from 'react';
1
+ import { useEffect, useState, useMemo } from 'react';
2
2
  import TableIcon from '../../icons/TableIcon';
3
3
  import AddMaterializationPopover from './AddMaterializationPopover';
4
4
  import * as React from 'react';
5
5
  import AddBackfillPopover from './AddBackfillPopover';
6
6
  import { labelize } from '../../../utils/form';
7
7
  import NodeMaterializationDelete from '../../components/NodeMaterializationDelete';
8
+ import Tab from '../../components/Tab';
9
+ import NodeRevisionMaterializationTab from './NodeRevisionMaterializationTab';
10
+ import AvailabilityStateBlock from './AvailabilityStateBlock';
8
11
 
9
12
  const cronstrue = require('cronstrue');
10
13
 
11
14
  export default function NodeMaterializationTab({ node, djClient }) {
12
- const [materializations, setMaterializations] = useState([]);
15
+ const [rawMaterializations, setRawMaterializations] = useState([]);
16
+ const [selectedRevisionTab, setSelectedRevisionTab] = useState(null);
17
+ const [showInactive, setShowInactive] = useState(false);
18
+ const [availabilityStates, setAvailabilityStates] = useState([]);
19
+ const [availabilityStatesByRevision, setAvailabilityStatesByRevision] =
20
+ useState({});
21
+ const [isRebuilding, setIsRebuilding] = useState(() => {
22
+ // Check if we're in the middle of a rebuild operation
23
+ return localStorage.getItem(`rebuilding-${node?.name}`) === 'true';
24
+ });
25
+
26
+ const filteredMaterializations = useMemo(() => {
27
+ return showInactive
28
+ ? rawMaterializations
29
+ : rawMaterializations.filter(mat => !mat.deactivated_at);
30
+ }, [rawMaterializations, showInactive]);
31
+
32
+ const materializationsByRevision = useMemo(() => {
33
+ return filteredMaterializations.reduce((acc, mat) => {
34
+ // Extract version from materialization config
35
+ const matVersion = mat.config?.cube?.version || node?.version;
36
+
37
+ if (!acc[matVersion]) {
38
+ acc[matVersion] = [];
39
+ }
40
+ acc[matVersion].push(mat);
41
+ return acc;
42
+ }, {});
43
+ }, [filteredMaterializations, node?.version]);
44
+
13
45
  useEffect(() => {
14
46
  const fetchData = async () => {
15
47
  if (node) {
16
48
  const data = await djClient.materializations(node.name);
17
- setMaterializations(data);
49
+
50
+ // Store raw data
51
+ setRawMaterializations(data);
52
+
53
+ // Fetch availability states
54
+ const availabilityData = await djClient.availabilityStates(node.name);
55
+ setAvailabilityStates(availabilityData);
56
+
57
+ // Group availability states by version
58
+ const availabilityGrouped = availabilityData.reduce((acc, avail) => {
59
+ const version = avail.node_version || node.version;
60
+ if (!acc[version]) {
61
+ acc[version] = [];
62
+ }
63
+ acc[version].push(avail);
64
+ return acc;
65
+ }, {});
66
+
67
+ setAvailabilityStatesByRevision(availabilityGrouped);
68
+
69
+ // Clear rebuilding state once data is loaded after a page reload
70
+ if (localStorage.getItem(`rebuilding-${node.name}`) === 'true') {
71
+ localStorage.removeItem(`rebuilding-${node.name}`);
72
+ setIsRebuilding(false);
73
+ }
18
74
  }
19
75
  };
20
76
  fetchData().catch(console.error);
21
77
  }, [djClient, node]);
22
78
 
79
+ // Separate useEffect to set default selected tab
80
+ useEffect(() => {
81
+ if (
82
+ !selectedRevisionTab &&
83
+ Object.keys(materializationsByRevision).length > 0
84
+ ) {
85
+ // First try to find current node version
86
+ if (materializationsByRevision[node?.version]) {
87
+ setSelectedRevisionTab(node.version);
88
+ } else {
89
+ // Otherwise, select the most recent version (sort by version string)
90
+ const sortedVersions = Object.keys(materializationsByRevision).sort(
91
+ (a, b) => b.localeCompare(a),
92
+ );
93
+ setSelectedRevisionTab(sortedVersions[0]);
94
+ }
95
+ }
96
+ }, [materializationsByRevision, selectedRevisionTab, node?.version]);
97
+
23
98
  const partitionColumnsMap = node
24
99
  ? Object.fromEntries(
25
100
  node?.columns
@@ -35,9 +110,198 @@ export default function NodeMaterializationTab({ node, djClient }) {
35
110
  return parsedCron;
36
111
  };
37
112
 
113
+ const onClickRevisionTab = revisionId => () => {
114
+ setSelectedRevisionTab(revisionId);
115
+ };
116
+
117
+ const buildRevisionTabs = () => {
118
+ const versions = Object.keys(materializationsByRevision);
119
+
120
+ // Check if there are any materializations at all (including inactive ones)
121
+ const hasAnyMaterializations = rawMaterializations.length > 0;
122
+
123
+ // Determine which versions have only inactive materializations
124
+ const versionHasOnlyInactive = {};
125
+ rawMaterializations.forEach(mat => {
126
+ const matVersion = mat.config?.cube?.version || node.version;
127
+ if (!versionHasOnlyInactive[matVersion]) {
128
+ versionHasOnlyInactive[matVersion] = {
129
+ hasActive: false,
130
+ hasInactive: false,
131
+ };
132
+ }
133
+ if (mat.deactivated_at) {
134
+ versionHasOnlyInactive[matVersion].hasInactive = true;
135
+ } else {
136
+ versionHasOnlyInactive[matVersion].hasActive = true;
137
+ }
138
+ });
139
+
140
+ // If no active versions but there are inactive materializations, show checkbox and button
141
+ if (versions.length === 0) {
142
+ return (
143
+ <div
144
+ style={{
145
+ display: 'flex',
146
+ alignItems: 'center',
147
+ justifyContent: 'flex-end',
148
+ marginBottom: '20px',
149
+ }}
150
+ >
151
+ <div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
152
+ {hasAnyMaterializations && (
153
+ <label
154
+ style={{
155
+ display: 'flex',
156
+ alignItems: 'center',
157
+ gap: '5px',
158
+ fontSize: '14px',
159
+ color: '#333',
160
+ padding: '4px 8px',
161
+ borderRadius: '12px',
162
+ backgroundColor: '#f5f5f5',
163
+ border: '1px solid #ddd',
164
+ }}
165
+ title="Shows inactive materializations for the latest cube."
166
+ >
167
+ <input
168
+ type="checkbox"
169
+ checked={showInactive}
170
+ onChange={e => setShowInactive(e.target.checked)}
171
+ />
172
+ Show Inactive
173
+ </label>
174
+ )}
175
+ {node && <AddMaterializationPopover node={node} />}
176
+ </div>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ // Sort versions: current version first, then by version string (most recent first)
182
+ const sortedVersions = versions.sort((a, b) => {
183
+ // Current node version always comes first
184
+ if (a === node?.version) return -1;
185
+ if (b === node?.version) return 1;
186
+
187
+ // Then sort by version string (descending)
188
+ return b.localeCompare(a);
189
+ });
190
+
191
+ // Check if latest version has active materializations
192
+ const hasLatestVersionMaterialization =
193
+ materializationsByRevision[node?.version] &&
194
+ materializationsByRevision[node?.version].length > 0;
195
+
196
+ // Refresh latest materialization function
197
+ const refreshLatestMaterialization = async () => {
198
+ if (
199
+ !window.confirm(
200
+ 'This will create a new version of the cube and build new materialization workflows. The previous version of the cube and its materialization will be accessible using a specific version label. Would you like to continue?',
201
+ )
202
+ ) {
203
+ return;
204
+ }
205
+
206
+ // Set loading state in both React state and localStorage
207
+ setIsRebuilding(true);
208
+ localStorage.setItem(`rebuilding-${node.name}`, 'true');
209
+
210
+ try {
211
+ const { status, json } = await djClient.refreshLatestMaterialization(
212
+ node.name,
213
+ );
214
+
215
+ if (status === 200 || status === 201) {
216
+ // Keep the loading state during page reload
217
+ window.location.reload(); // Reload to show the updated materialization
218
+ } else {
219
+ alert(`Failed to rebuild materialization: ${json.message}`);
220
+ // Clear loading state on error
221
+ localStorage.removeItem(`rebuilding-${node.name}`);
222
+ setIsRebuilding(false);
223
+ }
224
+ } catch (error) {
225
+ alert(`Error rebuilding materialization: ${error.message}`);
226
+ // Clear loading state on error
227
+ localStorage.removeItem(`rebuilding-${node.name}`);
228
+ setIsRebuilding(false);
229
+ }
230
+ };
231
+
232
+ return (
233
+ <div
234
+ style={{
235
+ display: 'flex',
236
+ alignItems: 'center',
237
+ justifyContent: 'space-between',
238
+ marginBottom: '20px',
239
+ }}
240
+ >
241
+ <div className="align-items-center row">
242
+ {sortedVersions.map(version => (
243
+ <NodeRevisionMaterializationTab
244
+ key={version}
245
+ version={version}
246
+ node={node}
247
+ selectedRevisionTab={selectedRevisionTab}
248
+ onClickRevisionTab={onClickRevisionTab}
249
+ showInactive={showInactive}
250
+ versionHasOnlyInactive={versionHasOnlyInactive}
251
+ />
252
+ ))}
253
+ </div>
254
+ <div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
255
+ <label
256
+ style={{
257
+ display: 'flex',
258
+ alignItems: 'center',
259
+ gap: '5px',
260
+ fontSize: '14px',
261
+ color: '#333',
262
+ padding: '4px 8px',
263
+ borderRadius: '12px',
264
+ backgroundColor: '#f5f5f5',
265
+ border: '1px solid #ddd',
266
+ }}
267
+ title="Shows inactive materializations for the latest cube."
268
+ >
269
+ <input
270
+ type="checkbox"
271
+ checked={showInactive}
272
+ onChange={e => setShowInactive(e.target.checked)}
273
+ />
274
+ Show Inactive
275
+ </label>
276
+ {node &&
277
+ (hasLatestVersionMaterialization ? (
278
+ <button
279
+ className="edit_button"
280
+ aria-label="RefreshLatestMaterialization"
281
+ tabIndex="0"
282
+ onClick={refreshLatestMaterialization}
283
+ disabled={isRebuilding}
284
+ title="Create a new version of the cube and re-create its materialization workflows."
285
+ style={{
286
+ opacity: isRebuilding ? 0.7 : 1,
287
+ cursor: isRebuilding ? 'not-allowed' : 'pointer',
288
+ }}
289
+ >
290
+ <span className="add_node">
291
+ Rebuild (latest) Materialization
292
+ </span>
293
+ </button>
294
+ ) : (
295
+ <AddMaterializationPopover node={node} />
296
+ ))}
297
+ </div>
298
+ </div>
299
+ );
300
+ };
301
+
38
302
  const materializationRows = materializations => {
39
- return materializations.map(materialization => (
40
- <>
303
+ return materializations.map((materialization, index) => (
304
+ <div key={`${materialization.name}-${index}`}>
41
305
  <div className="tr">
42
306
  <div key={materialization.name} style={{ fontSize: 'large' }}>
43
307
  <div
@@ -53,6 +317,7 @@ export default function NodeMaterializationTab({ node, djClient }) {
53
317
  <NodeMaterializationDelete
54
318
  nodeName={node.name}
55
319
  materializationName={materialization.name}
320
+ nodeVersion={selectedRevisionTab}
56
321
  />
57
322
  </div>
58
323
  <div className="td">
@@ -191,7 +456,7 @@ export default function NodeMaterializationTab({ node, djClient }) {
191
456
  .filter(col => col.partition !== null)
192
457
  .map(column => {
193
458
  return (
194
- <li>
459
+ <li key={column.name}>
195
460
  <div className="partitionLink">
196
461
  {column.display_name}
197
462
  <span className="badge partition_value">
@@ -206,20 +471,86 @@ export default function NodeMaterializationTab({ node, djClient }) {
206
471
  </ul>
207
472
  </div>
208
473
  </div>
209
- </>
474
+ </div>
210
475
  ));
211
476
  };
477
+ const currentRevisionMaterializations = selectedRevisionTab
478
+ ? materializationsByRevision[selectedRevisionTab] || []
479
+ : filteredMaterializations;
480
+
481
+ const currentRevisionAvailability = selectedRevisionTab
482
+ ? availabilityStatesByRevision[selectedRevisionTab] || []
483
+ : availabilityStates;
484
+
485
+ const renderMaterializedDatasets = availabilityStates => {
486
+ if (!availabilityStates || availabilityStates.length === 0) {
487
+ return (
488
+ <div className="message alert" style={{ marginTop: '10px' }}>
489
+ No materialized datasets available for this revision.
490
+ </div>
491
+ );
492
+ }
493
+
494
+ return availabilityStates.map((availability, index) => (
495
+ <AvailabilityStateBlock
496
+ key={`availability-${index}`}
497
+ availability={availability}
498
+ />
499
+ ));
500
+ };
501
+
212
502
  return (
213
503
  <>
214
504
  <div
215
505
  className="table-vertical"
216
506
  role="table"
217
507
  aria-label="Materializations"
508
+ style={{ position: 'relative' }}
218
509
  >
510
+ {/* Loading overlay */}
511
+ {isRebuilding && (
512
+ <div
513
+ style={{
514
+ position: 'absolute',
515
+ top: 0,
516
+ left: 0,
517
+ right: 0,
518
+ bottom: 0,
519
+ backgroundColor: 'rgba(255, 255, 255, 0.8)',
520
+ display: 'flex',
521
+ flexDirection: 'column',
522
+ justifyContent: 'center',
523
+ alignItems: 'center',
524
+ zIndex: 1000,
525
+ minHeight: '200px',
526
+ }}
527
+ >
528
+ <div
529
+ style={{
530
+ width: '40px',
531
+ height: '40px',
532
+ border: '4px solid #f3f3f3',
533
+ borderTop: '4px solid #3498db',
534
+ borderRadius: '50%',
535
+ animation: 'spin 1s linear infinite',
536
+ marginBottom: '16px',
537
+ }}
538
+ />
539
+ <div
540
+ style={{ fontSize: '16px', color: '#666', textAlign: 'center' }}
541
+ >
542
+ Rebuilding materialization...
543
+ <br />
544
+ <small style={{ fontSize: '14px' }}>
545
+ This may take a few moments
546
+ </small>
547
+ </div>
548
+ </div>
549
+ )}
550
+
219
551
  <div>
220
- <h2>Materializations</h2>
221
- {node ? <AddMaterializationPopover node={node} /> : <></>}
222
- {materializations.length > 0 ? (
552
+ {buildRevisionTabs()}
553
+ {currentRevisionMaterializations.length > 0 ? (
223
554
  <div
224
555
  className="card-inner-table table"
225
556
  aria-label="Materializations"
@@ -227,7 +558,7 @@ export default function NodeMaterializationTab({ node, djClient }) {
227
558
  >
228
559
  <div style={{ display: 'table' }}>
229
560
  {materializationRows(
230
- materializations.filter(
561
+ currentRevisionMaterializations.filter(
231
562
  materialization =>
232
563
  !(
233
564
  materialization.name === 'default' &&
@@ -239,88 +570,12 @@ export default function NodeMaterializationTab({ node, djClient }) {
239
570
  </div>
240
571
  ) : (
241
572
  <div className="message alert" style={{ marginTop: '10px' }}>
242
- No materialization workflows configured for this node.
573
+ No materialization workflows configured for this revision.
243
574
  </div>
244
575
  )}
245
- </div>
246
- <div>
247
- <h2>Materialized Datasets</h2>
248
- {node && node.availability !== null ? (
249
- <table
250
- className="card-inner-table table"
251
- aria-label="Availability"
252
- aria-hidden="false"
253
- >
254
- <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
255
- <tr>
256
- <th className="text-start">Output Dataset</th>
257
- <th>Valid Through</th>
258
- <th>Partitions</th>
259
- <th>Links</th>
260
- </tr>
261
- </thead>
262
- <tbody>
263
- <tr>
264
- <td>
265
- {
266
- <div
267
- className={`table__full`}
268
- key={node.availability.table}
269
- >
270
- <div className="table__header">
271
- <TableIcon />{' '}
272
- <span className={`entity-info`}>
273
- {node.availability.catalog +
274
- '.' +
275
- node.availability.schema_}
276
- </span>
277
- </div>
278
- <div className={`table__body upstream_tables`}>
279
- <a href={node.availability.url}>
280
- {node.availability.table}
281
- </a>
282
- </div>
283
- </div>
284
- }
285
- </td>
286
- <td>
287
- {new Date(node.availability.valid_through_ts).toISOString()}
288
- </td>
289
- <td>
290
- <span
291
- className={`badge partition_value`}
292
- style={{ fontSize: '100%' }}
293
- >
294
- <span className={`badge partition_value_highlight`}>
295
- {node.availability.min_temporal_partition}
296
- </span>
297
- to
298
- <span className={`badge partition_value_highlight`}>
299
- {node.availability.max_temporal_partition}
300
- </span>
301
- </span>
302
- </td>
303
- <td>
304
- {node.availability.links !== null ? (
305
- Object.entries(node.availability.links).map(
306
- ([key, value]) => (
307
- <div key={key}>
308
- <a href={value} target="_blank" rel="noreferrer">
309
- {key}
310
- </a>
311
- </div>
312
- ),
313
- )
314
- ) : (
315
- <></>
316
- )}
317
- </td>
318
- </tr>
319
- </tbody>
320
- </table>
321
- ) : (
322
- <div className="message alert" style={{ marginTop: '10px' }}>
323
- No materialized datasets available for this node.
576
+ {Object.keys(materializationsByRevision).length > 0 && (
577
+ <div style={{ marginTop: '30px' }}>
578
+ {renderMaterializedDatasets(currentRevisionAvailability)}
324
579
  </div>
325
580
  )}
326
581
  </div>
@@ -0,0 +1,58 @@
1
+ import Tab from '../../components/Tab';
2
+
3
+ export default function NodeRevisionMaterializationTab({
4
+ version,
5
+ node,
6
+ selectedRevisionTab,
7
+ onClickRevisionTab,
8
+ showInactive,
9
+ versionHasOnlyInactive,
10
+ }) {
11
+ const isCurrentVersion = version === node?.version;
12
+ const tabName = isCurrentVersion ? `${version} (latest)` : version;
13
+ const versionInfo = versionHasOnlyInactive[version];
14
+ const isOnlyInactive =
15
+ versionInfo && !versionInfo.hasActive && versionInfo.hasInactive;
16
+
17
+ // For inactive-only versions, render with oval styling
18
+ if (isOnlyInactive && showInactive) {
19
+ return (
20
+ <div
21
+ key={version}
22
+ className={selectedRevisionTab === version ? 'col active' : 'col'}
23
+ >
24
+ <div className="header-tabs nav-overflow nav nav-tabs">
25
+ <div className="nav-item">
26
+ <button
27
+ id={version}
28
+ className="nav-link"
29
+ tabIndex="0"
30
+ onClick={onClickRevisionTab(version)}
31
+ aria-label={tabName}
32
+ aria-hidden="false"
33
+ style={{
34
+ padding: '4px 8px',
35
+ borderRadius: '12px',
36
+ backgroundColor: '#f5f5f5',
37
+ border: '1px solid #ddd',
38
+ margin: '0 2px',
39
+ }}
40
+ >
41
+ {tabName}
42
+ </button>
43
+ </div>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ return (
50
+ <Tab
51
+ key={version}
52
+ id={version}
53
+ name={tabName}
54
+ onClick={onClickRevisionTab(version)}
55
+ selectedTab={selectedRevisionTab}
56
+ />
57
+ );
58
+ }
@@ -6,12 +6,19 @@ describe('<NodeMaterializationTab />', () => {
6
6
  const mockDjClient = {
7
7
  node: jest.fn(),
8
8
  materializations: jest.fn(),
9
+ availabilityStates: jest.fn(),
10
+ materializationInfo: jest.fn(),
11
+ refreshLatestMaterialization: jest.fn(),
9
12
  };
10
13
 
11
14
  const mockMaterializations = [
12
15
  {
13
16
  name: 'mat_one',
14
- config: {},
17
+ config: {
18
+ cube: {
19
+ version: 'v1.0',
20
+ },
21
+ },
15
22
  schedule: '@daily',
16
23
  job: 'SparkSqlMaterializationJob',
17
24
  backfills: [
@@ -29,6 +36,27 @@ describe('<NodeMaterializationTab />', () => {
29
36
  strategy: 'full',
30
37
  output_tables: ['table1'],
31
38
  urls: ['https://example.com/'],
39
+ deactivated_at: null,
40
+ },
41
+ ];
42
+
43
+ const mockAvailabilityStates = [
44
+ {
45
+ id: 1,
46
+ catalog: 'default',
47
+ schema_: 'foo',
48
+ table: 'bar',
49
+ valid_through_ts: 1729667463,
50
+ url: 'https://www.table.com',
51
+ links: { dashboard: 'https://www.foobar.com/dashboard' },
52
+ categorical_partitions: [],
53
+ temporal_partitions: [],
54
+ min_temporal_partition: ['2022', '01', '01'],
55
+ max_temporal_partition: ['2023', '01', '25'],
56
+ partitions: [],
57
+ updated_at: '2023-08-21T16:48:52.880498+00:00',
58
+ node_revision_id: 1,
59
+ node_version: 'v1.0',
32
60
  },
33
61
  ];
34
62
 
@@ -134,13 +162,27 @@ describe('<NodeMaterializationTab />', () => {
134
162
 
135
163
  beforeEach(() => {
136
164
  mockDjClient.materializations.mockReset();
165
+ mockDjClient.availabilityStates.mockReset();
166
+ mockDjClient.materializationInfo.mockReset();
137
167
  });
138
168
 
139
169
  it('renders NodeMaterializationTab tab correctly', async () => {
140
170
  mockDjClient.materializations.mockReturnValue(mockMaterializations);
171
+ mockDjClient.availabilityStates.mockReturnValue(mockAvailabilityStates);
172
+ mockDjClient.materializationInfo.mockReturnValue({
173
+ job_types: [],
174
+ strategies: [],
175
+ });
141
176
 
142
177
  render(<NodeMaterializationTab node={mockNode} djClient={mockDjClient} />);
143
178
  await waitFor(() => {
179
+ // Check that the version tab is rendered
180
+ expect(screen.getByText('v1.0 (latest)')).toBeInTheDocument();
181
+
182
+ // Check that the materialization is rendered
183
+ expect(screen.getByText('Spark Sql')).toBeInTheDocument();
184
+
185
+ // Check that the dashboard link is rendered in the availability section
144
186
  const link = screen.getByText('dashboard').closest('a');
145
187
  expect(link).toHaveAttribute('href', `https://www.foobar.com/dashboard`);
146
188
  });