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 +1 -1
- package/src/app/components/NodeMaterializationDelete.jsx +11 -1
- package/src/app/components/Search.jsx +1 -1
- package/src/app/pages/NamespacePage/__tests__/index.test.jsx +46 -45
- package/src/app/pages/NamespacePage/index.jsx +8 -26
- package/src/app/pages/NodePage/AddMaterializationPopover.jsx +17 -9
- package/src/app/pages/NodePage/AvailabilityStateBlock.jsx +67 -0
- package/src/app/pages/NodePage/NodeMaterializationTab.jsx +346 -91
- package/src/app/pages/NodePage/NodeRevisionMaterializationTab.jsx +58 -0
- package/src/app/pages/NodePage/__tests__/NodeMaterializationTab.test.jsx +43 -1
- package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +23 -10
- package/src/app/services/DJService.js +57 -13
- package/src/app/services/__tests__/DJService.test.jsx +55 -1
- package/src/styles/index.css +9 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
172
|
+
// flip direction
|
|
173
|
+
fireEvent.click(screen.getByText('name'));
|
|
174
|
+
await waitFor(() => {
|
|
175
|
+
expect(mockDjClient.listNodesForLanding).toHaveBeenCalledTimes(3);
|
|
176
|
+
});
|
|
162
177
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
// sort by 'displayName'
|
|
179
|
+
fireEvent.click(screen.getByText('display Name'));
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
expect(mockDjClient.listNodesForLanding).toHaveBeenCalledTimes(4);
|
|
182
|
+
});
|
|
167
183
|
|
|
168
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
<
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
198
|
+
<div
|
|
199
|
+
style={{
|
|
200
|
+
display: 'flex',
|
|
201
|
+
justifyContent: 'flex-end',
|
|
202
|
+
marginTop: '20px',
|
|
203
|
+
}}
|
|
204
204
|
>
|
|
205
|
-
|
|
206
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
-
|
|
221
|
-
{
|
|
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
|
-
|
|
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
|
|
573
|
+
No materialization workflows configured for this revision.
|
|
243
574
|
</div>
|
|
244
575
|
)}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
});
|