datajunction-ui 0.0.1-a111 → 0.0.1-a113

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.1a111",
3
+ "version": "0.0.1a113",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -8,6 +8,7 @@ import { displayMessageAfterSubmit } from '../../utils/form';
8
8
  export default function NodeMaterializationDelete({
9
9
  nodeName,
10
10
  materializationName,
11
+ nodeVersion = null,
11
12
  }) {
12
13
  const [deleteButton, setDeleteButton] = React.useState(<DeleteIcon />);
13
14
 
@@ -17,6 +18,8 @@ export default function NodeMaterializationDelete({
17
18
  !window.confirm(
18
19
  'Deleting materialization job ' +
19
20
  values.materializationName +
21
+ ' for node version ' +
22
+ values.nodeVersion +
20
23
  '. Are you sure?',
21
24
  )
22
25
  ) {
@@ -25,6 +28,7 @@ export default function NodeMaterializationDelete({
25
28
  const { status, json } = await djClient.deleteMaterialization(
26
29
  values.nodeName,
27
30
  values.materializationName,
31
+ values.nodeVersion,
28
32
  );
29
33
  if (status === 200 || status === 201 || status === 204) {
30
34
  window.location.reload();
@@ -47,11 +51,17 @@ export default function NodeMaterializationDelete({
47
51
  const initialValues = {
48
52
  nodeName: nodeName,
49
53
  materializationName: materializationName,
54
+ nodeVersion: nodeVersion,
50
55
  };
51
56
 
52
57
  return (
53
58
  <div>
54
- <Formik initialValues={initialValues} onSubmit={deleteNode}>
59
+ <Formik
60
+ key={`${nodeName}-${materializationName}-${nodeVersion}`}
61
+ initialValues={initialValues}
62
+ enableReinitialize={true}
63
+ onSubmit={deleteNode}
64
+ >
55
65
  {function Render({ status, setFieldValue }) {
56
66
  return (
57
67
  <Form className="deleteNode">
@@ -72,7 +72,7 @@ export default function Search() {
72
72
  />
73
73
  </form>
74
74
  <div className="search-results">
75
- {searchResults.map(item => {
75
+ {searchResults.slice(0, 20).map(item => {
76
76
  const itemUrl =
77
77
  item.type !== 'tag' ? `/nodes/${item.name}` : `/tags/${item.name}`;
78
78
  return (
@@ -146,59 +146,60 @@ describe('NamespacePage', () => {
146
146
  </MemoryRouter>,
147
147
  );
148
148
 
149
- await waitFor(
150
- () => {
151
- expect(mockDjClient.listNodesForLanding).toHaveBeenCalled();
152
- expect(screen.getByText('Namespaces')).toBeInTheDocument();
149
+ // Wait for initial nodes to load
150
+ await waitFor(() => {
151
+ expect(mockDjClient.listNodesForLanding).toHaveBeenCalled();
152
+ expect(screen.getByText('Namespaces')).toBeInTheDocument();
153
+ });
154
+
155
+ // Check that it displays namespaces
156
+ expect(screen.getByText('common')).toBeInTheDocument();
157
+ expect(screen.getByText('one')).toBeInTheDocument();
158
+ expect(screen.getByText('fruits')).toBeInTheDocument();
159
+ expect(screen.getByText('vegetables')).toBeInTheDocument();
160
+
161
+ // Check that it renders nodes
162
+ expect(screen.getByText('Test Node')).toBeInTheDocument();
153
163
 
154
- // check that it displays namespaces
155
- expect(screen.getByText('common')).toBeInTheDocument();
156
- expect(screen.getByText('one')).toBeInTheDocument();
157
- expect(screen.getByText('fruits')).toBeInTheDocument();
158
- expect(screen.getByText('vegetables')).toBeInTheDocument();
164
+ // --- Sorting ---
165
+
166
+ // sort by 'name'
167
+ fireEvent.click(screen.getByText('name'));
168
+ await waitFor(() => {
169
+ expect(mockDjClient.listNodesForLanding).toHaveBeenCalledTimes(2);
170
+ });
159
171
 
160
- // check that it renders nodes
161
- expect(screen.getByText('Test Node')).toBeInTheDocument();
172
+ // flip direction
173
+ fireEvent.click(screen.getByText('name'));
174
+ await waitFor(() => {
175
+ expect(mockDjClient.listNodesForLanding).toHaveBeenCalledTimes(3);
176
+ });
162
177
 
163
- // check that it sorts nodes
164
- fireEvent.click(screen.getByText('name'));
165
- fireEvent.click(screen.getByText('name'));
166
- fireEvent.click(screen.getByText('display Name'));
178
+ // sort by 'displayName'
179
+ fireEvent.click(screen.getByText('display Name'));
180
+ await waitFor(() => {
181
+ expect(mockDjClient.listNodesForLanding).toHaveBeenCalledTimes(4);
182
+ });
167
183
 
168
- // paginate
169
- const previousButton = screen.getByText('← Previous');
170
- expect(previousButton).toBeDefined();
171
- fireEvent.click(previousButton);
172
- const nextButton = screen.getByText('Next →');
173
- expect(nextButton).toBeDefined();
174
- fireEvent.click(nextButton);
184
+ // --- Filters ---
175
185
 
176
- // check that we can filter by node type
177
- const selectNodeType = screen.getAllByTestId('select-node-type')[0];
178
- expect(selectNodeType).toBeDefined();
179
- expect(selectNodeType).not.toBeNull();
180
- fireEvent.keyDown(selectNodeType.firstChild, { key: 'ArrowDown' });
181
- fireEvent.click(screen.getByText('Source'));
186
+ // Node type
187
+ const selectNodeType = screen.getAllByTestId('select-node-type')[0];
188
+ fireEvent.keyDown(selectNodeType.firstChild, { key: 'ArrowDown' });
189
+ fireEvent.click(screen.getByText('Source'));
182
190
 
183
- // check that we can filter by tag
184
- const selectTag = screen.getAllByTestId('select-tag')[0];
185
- expect(selectTag).toBeDefined();
186
- expect(selectTag).not.toBeNull();
187
- fireEvent.keyDown(selectTag.firstChild, { key: 'ArrowDown' });
191
+ // Tag filter
192
+ const selectTag = screen.getAllByTestId('select-tag')[0];
193
+ fireEvent.keyDown(selectTag.firstChild, { key: 'ArrowDown' });
188
194
 
189
- // check that we can filter by user
190
- const selectUser = screen.getAllByTestId('select-user')[0];
191
- expect(selectUser).toBeDefined();
192
- expect(selectUser).not.toBeNull();
193
- fireEvent.keyDown(selectUser.firstChild, { key: 'ArrowDown' });
195
+ // User filter
196
+ const selectUser = screen.getAllByTestId('select-user')[0];
197
+ fireEvent.keyDown(selectUser.firstChild, { key: 'ArrowDown' });
194
198
 
195
- // click to open and close tab
196
- fireEvent.click(screen.getByText('common'));
197
- fireEvent.click(screen.getByText('common'));
198
- },
199
- { timeout: 3000 },
200
- );
201
- }, 60000);
199
+ // --- Expand/Collapse Namespace ---
200
+ fireEvent.click(screen.getByText('common'));
201
+ fireEvent.click(screen.getByText('common'));
202
+ });
202
203
 
203
204
  it('can add new namespace via add namespace popover', async () => {
204
205
  mockDjClient.addNamespace.mockReturnValue({
@@ -53,34 +53,14 @@ export function NamespacePage() {
53
53
  const [hasNextPage, setHasNextPage] = useState(true);
54
54
  const [hasPrevPage, setHasPrevPage] = useState(true);
55
55
 
56
- const sortedNodes = React.useMemo(() => {
57
- let sortableData = [...Object.values(state.nodes)];
58
- if (sortConfig !== null) {
59
- sortableData.sort((a, b) => {
60
- if (
61
- a[sortConfig.key] < b[sortConfig.key] ||
62
- a.current[sortConfig.key] < b.current[sortConfig.key]
63
- ) {
64
- return sortConfig.direction === ASC ? -1 : 1;
65
- }
66
- if (
67
- a[sortConfig.key] > b[sortConfig.key] ||
68
- a.current[sortConfig.key] > b.current[sortConfig.key]
69
- ) {
70
- return sortConfig.direction === ASC ? 1 : -1;
71
- }
72
- return 0;
73
- });
74
- }
75
- return sortableData;
76
- }, [state.nodes, filters, sortConfig]);
77
-
78
56
  const requestSort = key => {
79
57
  let direction = ASC;
80
58
  if (sortConfig.key === key && sortConfig.direction === ASC) {
81
59
  direction = DESC;
82
60
  }
83
- setSortConfig({ key, direction });
61
+ if (sortConfig.key !== key || sortConfig.direction !== direction) {
62
+ setSortConfig({ key, direction });
63
+ }
84
64
  };
85
65
 
86
66
  const getClassNamesFor = name => {
@@ -141,6 +121,7 @@ export function NamespacePage() {
141
121
  before,
142
122
  after,
143
123
  50,
124
+ sortConfig,
144
125
  );
145
126
 
146
127
  setState({
@@ -170,7 +151,8 @@ export function NamespacePage() {
170
151
  setRetrieved(true);
171
152
  };
172
153
  fetchData().catch(console.error);
173
- }, [djClient, filters, before, after]);
154
+ }, [djClient, filters, before, after, sortConfig.key, sortConfig.direction]);
155
+
174
156
  const loadNext = () => {
175
157
  if (nextCursor) {
176
158
  setAfter(nextCursor);
@@ -185,8 +167,8 @@ export function NamespacePage() {
185
167
  };
186
168
 
187
169
  const nodesList = retrieved ? (
188
- sortedNodes.length > 0 ? (
189
- sortedNodes.map(node => (
170
+ state.nodes.length > 0 ? (
171
+ state.nodes.map(node => (
190
172
  <tr key={node.name}>
191
173
  <td>
192
174
  <a href={'/nodes/' + node.name} className="link-table">
@@ -124,7 +124,7 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
124
124
  {function Render({ isSubmitting, status, setFieldValue }) {
125
125
  return (
126
126
  <Form>
127
- <h2>Configure Materialization</h2>
127
+ <h2>Configure Materialization for the Latest Node Version</h2>
128
128
  {displayMessageAfterSubmit(status)}
129
129
  {node.type === 'cube' ? (
130
130
  <span data-testid="job-type">
@@ -195,15 +195,23 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
195
195
  'spark.memory.fraction': '0.3',
196
196
  }}
197
197
  />
198
- <button
199
- className="add_node"
200
- type="submit"
201
- aria-label="SaveEditColumn"
202
- aria-hidden="false"
203
- disabled={isSubmitting}
198
+ <div
199
+ style={{
200
+ display: 'flex',
201
+ justifyContent: 'flex-end',
202
+ marginTop: '20px',
203
+ }}
204
204
  >
205
- {isSubmitting ? <LoadingIcon /> : 'Save'}
206
- </button>
205
+ <button
206
+ className="add_node"
207
+ type="submit"
208
+ aria-label="SaveEditColumn"
209
+ aria-hidden="false"
210
+ disabled={isSubmitting}
211
+ >
212
+ {isSubmitting ? <LoadingIcon /> : 'Save'}
213
+ </button>
214
+ </div>
207
215
  </Form>
208
216
  );
209
217
  }}
@@ -0,0 +1,67 @@
1
+ import TableIcon from '../../icons/TableIcon';
2
+
3
+ export default function AvailabilityStateBlock({ availability }) {
4
+ return (
5
+ <table
6
+ className="card-inner-table table"
7
+ aria-label="Availability"
8
+ aria-hidden="false"
9
+ style={{ marginBottom: '20px' }}
10
+ >
11
+ <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
12
+ <tr>
13
+ <th className="text-start">Output Dataset</th>
14
+ <th>Valid Through</th>
15
+ <th>Partitions</th>
16
+ <th>Links</th>
17
+ </tr>
18
+ </thead>
19
+ <tbody>
20
+ <tr>
21
+ <td>
22
+ <div className={`table__full`} key={availability.table}>
23
+ <div className="table__header">
24
+ <TableIcon />{' '}
25
+ <span className={`entity-info`}>
26
+ {availability.catalog + '.' + availability.schema_}
27
+ </span>
28
+ </div>
29
+ <div className={`table__body upstream_tables`}>
30
+ <a href={availability.url}>{availability.table}</a>
31
+ </div>
32
+ </div>
33
+ </td>
34
+ <td>{new Date(availability.valid_through_ts).toISOString()}</td>
35
+ <td>
36
+ <span
37
+ className={`badge partition_value`}
38
+ style={{ fontSize: '100%' }}
39
+ >
40
+ <span className={`badge partition_value_highlight`}>
41
+ {availability.min_temporal_partition?.join(', ') || 'N/A'}
42
+ </span>
43
+ to
44
+ <span className={`badge partition_value_highlight`}>
45
+ {availability.max_temporal_partition?.join(', ') || 'N/A'}
46
+ </span>
47
+ </span>
48
+ </td>
49
+ <td>
50
+ {availability.links &&
51
+ Object.keys(availability.links).length > 0 ? (
52
+ Object.entries(availability.links).map(([key, value]) => (
53
+ <div key={key}>
54
+ <a href={value} target="_blank" rel="noreferrer">
55
+ {key}
56
+ </a>
57
+ </div>
58
+ ))
59
+ ) : (
60
+ <></>
61
+ )}
62
+ </td>
63
+ </tr>
64
+ </tbody>
65
+ </table>
66
+ );
67
+ }
@@ -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
  });
@@ -31,6 +31,8 @@ describe('<NodePage />', () => {
31
31
  history: jest.fn(),
32
32
  revisions: jest.fn(),
33
33
  materializations: jest.fn(),
34
+ availabilityStates: jest.fn(),
35
+ refreshLatestMaterialization: jest.fn(),
34
36
  materializationInfo: jest.fn(),
35
37
  sql: jest.fn(),
36
38
  cube: jest.fn(),
@@ -43,6 +45,11 @@ describe('<NodePage />', () => {
43
45
  engines: jest.fn(),
44
46
  streamNodeData: jest.fn(),
45
47
  nodeDimensions: jest.fn(),
48
+ getNotificationPreferences: jest.fn().mockResolvedValue([]),
49
+ subscribeToNotifications: jest.fn().mockResolvedValue({ status: 200 }),
50
+ unsubscribeFromNotifications: jest
51
+ .fn()
52
+ .mockResolvedValue({ status: 200 }),
46
53
  },
47
54
  };
48
55
  };
@@ -588,6 +595,7 @@ describe('<NodePage />', () => {
588
595
  );
589
596
  djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
590
597
  djClient.DataJunctionAPI.materializations.mockReturnValue([]);
598
+ djClient.DataJunctionAPI.availabilityStates.mockReturnValue([]);
591
599
 
592
600
  const element = (
593
601
  <DJClientContext.Provider value={djClient}>
@@ -603,16 +611,20 @@ describe('<NodePage />', () => {
603
611
  </Routes>
604
612
  </MemoryRouter>,
605
613
  );
606
- await waitFor(() => {
607
- fireEvent.click(screen.getByRole('button', { name: 'Materializations' }));
608
- expect(djClient.DataJunctionAPI.materializations).toHaveBeenCalledWith(
609
- mocks.mockMetricNode.name,
610
- );
611
- screen.getByText(
612
- 'No materialization workflows configured for this node.',
613
- );
614
- screen.getByText('No materialized datasets available for this node.');
615
- });
614
+ await waitFor(
615
+ () => {
616
+ fireEvent.click(
617
+ screen.getByRole('button', { name: 'Materializations' }),
618
+ );
619
+ expect(djClient.DataJunctionAPI.materializations).toHaveBeenCalledWith(
620
+ mocks.mockMetricNode.name,
621
+ );
622
+ screen.getByText(
623
+ 'No materialization workflows configured for this revision.',
624
+ );
625
+ },
626
+ { timeout: 5000 },
627
+ );
616
628
  });
617
629
 
618
630
  it('renders the NodeMaterialization tab with materializations correctly', async () => {
@@ -625,6 +637,7 @@ describe('<NodePage />', () => {
625
637
  djClient.DataJunctionAPI.materializations.mockReturnValue(
626
638
  mocks.nodeMaterializations,
627
639
  );
640
+ djClient.DataJunctionAPI.availabilityStates.mockReturnValue([]);
628
641
 
629
642
  djClient.DataJunctionAPI.materializationInfo.mockReturnValue(
630
643
  mocks.materializationInfo,
@@ -17,9 +17,10 @@ export const DataJunctionAPI = {
17
17
  before,
18
18
  after,
19
19
  limit,
20
+ sortConfig,
20
21
  ) {
21
22
  const query = `
22
- query ListNodes($namespace: String, $nodeTypes: [NodeType!], $tags: [String!], $editedBy: String, $before: String, $after: String, $limit: Int) {
23
+ query ListNodes($namespace: String, $nodeTypes: [NodeType!], $tags: [String!], $editedBy: String, $before: String, $after: String, $limit: Int, $orderBy: NodeSortField, $ascending: Boolean) {
23
24
  findNodesPaginated(
24
25
  namespace: $namespace
25
26
  nodeTypes: $nodeTypes
@@ -28,6 +29,8 @@ export const DataJunctionAPI = {
28
29
  limit: $limit
29
30
  before: $before
30
31
  after: $after
32
+ orderBy: $orderBy,
33
+ ascending: $ascending
31
34
  ) {
32
35
  pageInfo {
33
36
  hasNextPage
@@ -58,6 +61,13 @@ export const DataJunctionAPI = {
58
61
  }
59
62
  }
60
63
  `;
64
+ const sortOrderMapping = {
65
+ name: 'NAME',
66
+ displayName: 'DISPLAY_NAME',
67
+ type: 'TYPE',
68
+ status: 'STATUS',
69
+ updatedAt: 'UPDATED_AT',
70
+ };
61
71
 
62
72
  return await (
63
73
  await fetch(DJ_GQL, {
@@ -76,6 +86,8 @@ export const DataJunctionAPI = {
76
86
  before: before,
77
87
  after: after,
78
88
  limit: limit,
89
+ orderBy: sortOrderMapping[sortConfig.key],
90
+ ascending: sortConfig.direction === 'ascending',
79
91
  },
80
92
  }),
81
93
  })
@@ -571,7 +583,8 @@ export const DataJunctionAPI = {
571
583
  filters,
572
584
  owners,
573
585
  ) {
574
- const response = await fetch(`${DJ_URL}/nodes/${name}`, {
586
+ const url = `${DJ_URL}/nodes/${name}`;
587
+ const response = await fetch(url, {
575
588
  method: 'PATCH',
576
589
  headers: {
577
590
  'Content-Type': 'application/json',
@@ -590,6 +603,19 @@ export const DataJunctionAPI = {
590
603
  return { status: response.status, json: await response.json() };
591
604
  },
592
605
 
606
+ refreshLatestMaterialization: async function (name) {
607
+ const url = `${DJ_URL}/nodes/${name}?refresh_materialization=true`;
608
+ const response = await fetch(url, {
609
+ method: 'PATCH',
610
+ headers: {
611
+ 'Content-Type': 'application/json',
612
+ },
613
+ body: JSON.stringify({}),
614
+ credentials: 'include',
615
+ });
616
+ return { status: response.status, json: await response.json() };
617
+ },
618
+
593
619
  registerTable: async function (catalog, schema, table) {
594
620
  const response = await fetch(
595
621
  `${DJ_URL}/register/table/${catalog}/${schema}/${table}`,
@@ -743,7 +769,20 @@ export const DataJunctionAPI = {
743
769
 
744
770
  materializations: async function (node) {
745
771
  const data = await (
746
- await fetch(`${DJ_URL}/nodes/${node}/materializations/`, {
772
+ await fetch(
773
+ `${DJ_URL}/nodes/${node}/materializations?show_inactive=true&include_all_revisions=true`,
774
+ {
775
+ credentials: 'include',
776
+ },
777
+ )
778
+ ).json();
779
+
780
+ return data;
781
+ },
782
+
783
+ availabilityStates: async function (node) {
784
+ const data = await (
785
+ await fetch(`${DJ_URL}/nodes/${node}/availability/`, {
747
786
  credentials: 'include',
748
787
  })
749
788
  ).json();
@@ -1276,17 +1315,22 @@ export const DataJunctionAPI = {
1276
1315
  );
1277
1316
  return { status: response.status, json: await response.json() };
1278
1317
  },
1279
- deleteMaterialization: async function (nodeName, materializationName) {
1280
- const response = await fetch(
1281
- `${DJ_URL}/nodes/${nodeName}/materializations?materialization_name=${materializationName}`,
1282
- {
1283
- method: 'DELETE',
1284
- headers: {
1285
- 'Content-Type': 'application/json',
1286
- },
1287
- credentials: 'include',
1318
+ deleteMaterialization: async function (
1319
+ nodeName,
1320
+ materializationName,
1321
+ nodeVersion = null,
1322
+ ) {
1323
+ let url = `${DJ_URL}/nodes/${nodeName}/materializations?materialization_name=${materializationName}`;
1324
+ if (nodeVersion) {
1325
+ url += `&node_version=${nodeVersion}`;
1326
+ }
1327
+ const response = await fetch(url, {
1328
+ method: 'DELETE',
1329
+ headers: {
1330
+ 'Content-Type': 'application/json',
1288
1331
  },
1289
- );
1332
+ credentials: 'include',
1333
+ });
1290
1334
  return { status: response.status, json: await response.json() };
1291
1335
  },
1292
1336
  listMetricMetadata: async function () {
@@ -479,7 +479,7 @@ describe('DataJunctionAPI', () => {
479
479
 
480
480
  // Check the first fetch call
481
481
  expect(fetch).toHaveBeenCalledWith(
482
- `${DJ_URL}/nodes/${nodeName}/materializations/`,
482
+ `${DJ_URL}/nodes/${nodeName}/materializations?show_inactive=true&include_all_revisions=true`,
483
483
  {
484
484
  credentials: 'include',
485
485
  },
@@ -1085,6 +1085,10 @@ describe('DataJunctionAPI', () => {
1085
1085
  null,
1086
1086
  null,
1087
1087
  100,
1088
+ {
1089
+ key: 'updatedAt',
1090
+ direction: 'descending',
1091
+ },
1088
1092
  );
1089
1093
  expect(fetch).toHaveBeenCalledWith(
1090
1094
  `${DJ_URL}/graphql`,
@@ -1502,4 +1506,54 @@ describe('DataJunctionAPI', () => {
1502
1506
  credentials: 'include',
1503
1507
  });
1504
1508
  });
1509
+
1510
+ it('calls availabilityStates correctly', async () => {
1511
+ const nodeName = 'default.sample_node';
1512
+ const mockAvailabilityStates = [
1513
+ {
1514
+ id: 1,
1515
+ catalog: 'test_catalog',
1516
+ schema_: 'test_schema',
1517
+ table: 'test_table',
1518
+ valid_through_ts: 1640995200,
1519
+ url: 'http://example.com/table',
1520
+ node_revision_id: 123,
1521
+ node_version: '1.0.0',
1522
+ },
1523
+ ];
1524
+
1525
+ fetch.mockResponseOnce(JSON.stringify(mockAvailabilityStates));
1526
+
1527
+ const result = await DataJunctionAPI.availabilityStates(nodeName);
1528
+
1529
+ expect(fetch).toHaveBeenCalledWith(
1530
+ `${DJ_URL}/nodes/${nodeName}/availability/`,
1531
+ {
1532
+ credentials: 'include',
1533
+ },
1534
+ );
1535
+ expect(result).toEqual(mockAvailabilityStates);
1536
+ });
1537
+
1538
+ it('calls refreshLatestMaterialization correctly', async () => {
1539
+ const nodeName = 'default.sample_cube';
1540
+ const mockResponse = { message: 'Materialization refreshed successfully' };
1541
+
1542
+ fetch.mockResponseOnce(JSON.stringify(mockResponse));
1543
+
1544
+ const result = await DataJunctionAPI.refreshLatestMaterialization(nodeName);
1545
+
1546
+ expect(fetch).toHaveBeenCalledWith(
1547
+ `${DJ_URL}/nodes/${nodeName}?refresh_materialization=true`,
1548
+ {
1549
+ method: 'PATCH',
1550
+ headers: {
1551
+ 'Content-Type': 'application/json',
1552
+ },
1553
+ body: JSON.stringify({}),
1554
+ credentials: 'include',
1555
+ },
1556
+ );
1557
+ expect(result).toEqual({ status: 200, json: mockResponse });
1558
+ });
1505
1559
  });
@@ -1545,3 +1545,12 @@ table {
1545
1545
  .dropdown:hover .dropdown-menu {
1546
1546
  display: block;
1547
1547
  }
1548
+
1549
+ @keyframes spin {
1550
+ 0% {
1551
+ transform: rotate(0deg);
1552
+ }
1553
+ 100% {
1554
+ transform: rotate(360deg);
1555
+ }
1556
+ }