datajunction-ui 0.0.1-a35.dev0 → 0.0.1-a36
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/forms/Action.jsx +8 -0
- package/src/app/components/forms/NodeNameField.jsx +64 -0
- package/src/app/components/forms/NodeTagsInput.jsx +61 -0
- package/src/app/index.tsx +18 -0
- package/src/app/pages/AddEditNodePage/__tests__/AddEditNodePageFormFailed.test.jsx +1 -1
- package/src/app/pages/AddEditNodePage/__tests__/AddEditNodePageFormSuccess.test.jsx +1 -1
- package/src/app/pages/AddEditNodePage/index.jsx +5 -1
- package/src/app/pages/CubeBuilderPage/DimensionsSelect.jsx +154 -0
- package/src/app/pages/CubeBuilderPage/Loadable.jsx +16 -0
- package/src/app/pages/CubeBuilderPage/MetricsSelect.jsx +79 -0
- package/src/app/pages/CubeBuilderPage/__tests__/index.test.jsx +378 -0
- package/src/app/pages/CubeBuilderPage/index.jsx +254 -0
- package/src/app/pages/NamespacePage/index.jsx +5 -0
- package/src/app/pages/NodePage/NodeInfoTab.jsx +6 -1
- package/src/app/pages/NodePage/__tests__/AddBackfillPopover.test.jsx +0 -1
- package/src/app/services/DJService.js +55 -0
- package/src/app/services/__tests__/DJService.test.jsx +67 -0
- package/src/styles/index.css +4 -0
- package/src/styles/node-creation.scss +12 -0
package/package.json
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ErrorMessage, Field } from 'formik';
|
|
2
|
+
import { FormikSelect } from '../../pages/AddEditNodePage/FormikSelect';
|
|
3
|
+
import { FullNameField } from '../../pages/AddEditNodePage/FullNameField';
|
|
4
|
+
import React, { useContext, useEffect, useState } from 'react';
|
|
5
|
+
import DJClientContext from '../../providers/djclient';
|
|
6
|
+
import { useParams } from 'react-router-dom';
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* This component creates the namespace selector, display name input, and
|
|
10
|
+
* derived name fields in a form. It can be reused any time we need to create
|
|
11
|
+
* a new node.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export default function NodeNameField() {
|
|
15
|
+
const [namespaces, setNamespaces] = useState([]);
|
|
16
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
17
|
+
let { initialNamespace } = useParams();
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const fetchData = async () => {
|
|
21
|
+
const namespaces = await djClient.namespaces();
|
|
22
|
+
setNamespaces(
|
|
23
|
+
namespaces.map(m => ({
|
|
24
|
+
value: m['namespace'],
|
|
25
|
+
label: m['namespace'],
|
|
26
|
+
})),
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
fetchData().catch(console.error);
|
|
30
|
+
}, [djClient, djClient.metrics]);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<>
|
|
34
|
+
<div className="NamespaceInput">
|
|
35
|
+
<ErrorMessage name="namespace" component="span" />
|
|
36
|
+
<label htmlFor="react-select-3-input">Namespace *</label>
|
|
37
|
+
<FormikSelect
|
|
38
|
+
selectOptions={namespaces}
|
|
39
|
+
formikFieldName="namespace"
|
|
40
|
+
placeholder="Choose Namespace"
|
|
41
|
+
defaultValue={{
|
|
42
|
+
value: initialNamespace,
|
|
43
|
+
label: initialNamespace,
|
|
44
|
+
}}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="DisplayNameInput NodeCreationInput">
|
|
48
|
+
<ErrorMessage name="display_name" component="span" />
|
|
49
|
+
<label htmlFor="displayName">Display Name *</label>
|
|
50
|
+
<Field
|
|
51
|
+
type="text"
|
|
52
|
+
name="display_name"
|
|
53
|
+
id="displayName"
|
|
54
|
+
placeholder="Human readable display name"
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
<div className="FullNameInput NodeCreationInput">
|
|
58
|
+
<ErrorMessage name="name" component="span" />
|
|
59
|
+
<label htmlFor="FullName">Full Name</label>
|
|
60
|
+
<FullNameField type="text" name="name" />
|
|
61
|
+
</div>
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ErrorMessage } from 'formik';
|
|
2
|
+
import { FormikSelect } from '../../pages/AddEditNodePage/FormikSelect';
|
|
3
|
+
import { Action } from './Action';
|
|
4
|
+
import { useContext, useEffect, useState } from 'react';
|
|
5
|
+
import DJClientContext from '../../providers/djclient';
|
|
6
|
+
|
|
7
|
+
export default function NodeTagsInput({ action, node }) {
|
|
8
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
9
|
+
const [tags, setTags] = useState([]);
|
|
10
|
+
|
|
11
|
+
// Get list of tags
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const fetchData = async () => {
|
|
14
|
+
const tags = await djClient.listTags();
|
|
15
|
+
setTags(
|
|
16
|
+
tags.map(tag => ({
|
|
17
|
+
value: tag.name,
|
|
18
|
+
label: tag.display_name,
|
|
19
|
+
})),
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
fetchData().catch(console.error);
|
|
23
|
+
}, [djClient, djClient.listTags]);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="TagsInput"
|
|
28
|
+
style={{ width: '25%', margin: '1rem 0 1rem 1.2rem' }}
|
|
29
|
+
>
|
|
30
|
+
<ErrorMessage name="tags" component="span" />
|
|
31
|
+
<label htmlFor="react-select-3-input">Tags</label>
|
|
32
|
+
<span data-testid="select-tags">
|
|
33
|
+
{action === Action.Edit && node?.tags?.length >= 0 ? (
|
|
34
|
+
<FormikSelect
|
|
35
|
+
className=""
|
|
36
|
+
isMulti={true}
|
|
37
|
+
selectOptions={tags}
|
|
38
|
+
formikFieldName="tags"
|
|
39
|
+
placeholder="Choose Tags"
|
|
40
|
+
defaultValue={node?.tags?.map(t => {
|
|
41
|
+
return { value: t.name, label: t.display_name };
|
|
42
|
+
})}
|
|
43
|
+
/>
|
|
44
|
+
) : (
|
|
45
|
+
''
|
|
46
|
+
)}
|
|
47
|
+
{action === Action.Add ? (
|
|
48
|
+
<FormikSelect
|
|
49
|
+
className=""
|
|
50
|
+
isMulti={true}
|
|
51
|
+
selectOptions={tags}
|
|
52
|
+
formikFieldName="tags"
|
|
53
|
+
placeholder="Choose Tags"
|
|
54
|
+
/>
|
|
55
|
+
) : (
|
|
56
|
+
''
|
|
57
|
+
)}
|
|
58
|
+
</span>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
package/src/app/index.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
|
10
10
|
import { NamespacePage } from './pages/NamespacePage/Loadable';
|
|
11
11
|
import { NodePage } from './pages/NodePage/Loadable';
|
|
12
12
|
import { SQLBuilderPage } from './pages/SQLBuilderPage/Loadable';
|
|
13
|
+
import { CubeBuilderPage } from './pages/CubeBuilderPage/Loadable';
|
|
13
14
|
import { TagPage } from './pages/TagPage/Loadable';
|
|
14
15
|
import { AddEditNodePage } from './pages/AddEditNodePage/Loadable';
|
|
15
16
|
import { AddEditTagPage } from './pages/AddEditTagPage/Loadable';
|
|
@@ -52,6 +53,11 @@ export function App() {
|
|
|
52
53
|
key="edit"
|
|
53
54
|
element={<AddEditNodePage />}
|
|
54
55
|
/>
|
|
56
|
+
<Route
|
|
57
|
+
path=":name/edit-cube"
|
|
58
|
+
key="edit-cube"
|
|
59
|
+
element={<CubeBuilderPage />}
|
|
60
|
+
/>
|
|
55
61
|
</Route>
|
|
56
62
|
|
|
57
63
|
<Route path="/" element={<NamespacePage />} key="index" />
|
|
@@ -72,6 +78,18 @@ export function App() {
|
|
|
72
78
|
key="register"
|
|
73
79
|
element={<RegisterTablePage />}
|
|
74
80
|
></Route>
|
|
81
|
+
<Route path="/create/cube">
|
|
82
|
+
<Route
|
|
83
|
+
path=":initialNamespace"
|
|
84
|
+
key="create"
|
|
85
|
+
element={<CubeBuilderPage />}
|
|
86
|
+
/>
|
|
87
|
+
<Route
|
|
88
|
+
path=""
|
|
89
|
+
key="create"
|
|
90
|
+
element={<CubeBuilderPage />}
|
|
91
|
+
/>
|
|
92
|
+
</Route>
|
|
75
93
|
<Route path="create/:nodeType">
|
|
76
94
|
<Route
|
|
77
95
|
path=":initialNamespace"
|
|
@@ -93,7 +93,7 @@ describe('AddEditNodePage submission failed', () => {
|
|
|
93
93
|
expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalled();
|
|
94
94
|
expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalledWith(
|
|
95
95
|
'default.num_repair_orders',
|
|
96
|
-
['purpose'],
|
|
96
|
+
[{ display_name: 'Purpose', name: 'purpose' }],
|
|
97
97
|
);
|
|
98
98
|
expect(mockDjClient.DataJunctionAPI.tagsNode).toReturnWith({
|
|
99
99
|
json: { message: 'Some tags were not found' },
|
|
@@ -120,7 +120,7 @@ describe('AddEditNodePage submission succeeded', () => {
|
|
|
120
120
|
expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalledTimes(1);
|
|
121
121
|
expect(mockDjClient.DataJunctionAPI.tagsNode).toBeCalledWith(
|
|
122
122
|
'default.num_repair_orders',
|
|
123
|
-
['purpose'],
|
|
123
|
+
[{ display_name: 'Purpose', name: 'purpose' }],
|
|
124
124
|
);
|
|
125
125
|
|
|
126
126
|
expect(mockDjClient.DataJunctionAPI.listMetricMetadata).toBeCalledTimes(
|
|
@@ -10,7 +10,7 @@ import { useContext, useEffect, useState } from 'react';
|
|
|
10
10
|
import DJClientContext from '../../providers/djclient';
|
|
11
11
|
import 'styles/node-creation.scss';
|
|
12
12
|
import AlertIcon from '../../icons/AlertIcon';
|
|
13
|
-
import { useParams } from 'react-router-dom';
|
|
13
|
+
import { useParams, useNavigate } from 'react-router-dom';
|
|
14
14
|
import { FullNameField } from './FullNameField';
|
|
15
15
|
import { FormikSelect } from './FormikSelect';
|
|
16
16
|
import { NodeQueryField } from './NodeQueryField';
|
|
@@ -27,6 +27,7 @@ class Action {
|
|
|
27
27
|
|
|
28
28
|
export function AddEditNodePage() {
|
|
29
29
|
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
30
|
+
const navigate = useNavigate();
|
|
30
31
|
|
|
31
32
|
let { nodeType, initialNamespace, name } = useParams();
|
|
32
33
|
const action = name !== undefined ? Action.Edit : Action.Add;
|
|
@@ -363,6 +364,9 @@ export function AddEditNodePage() {
|
|
|
363
364
|
// Check if node type can be edited
|
|
364
365
|
if (!nodeCanBeEdited(data.type)) {
|
|
365
366
|
setNode(null);
|
|
367
|
+
if (data.type === 'cube') {
|
|
368
|
+
navigate(`/nodes/${data.name}/edit-cube`);
|
|
369
|
+
}
|
|
366
370
|
setMessage(
|
|
367
371
|
`Node ${name} is of type ${data.type} and cannot be edited`,
|
|
368
372
|
);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A select component for picking dimensions
|
|
3
|
+
*/
|
|
4
|
+
import { useField, useFormikContext } from 'formik';
|
|
5
|
+
import Select from 'react-select';
|
|
6
|
+
import React, { useContext, useEffect, useState } from 'react';
|
|
7
|
+
import DJClientContext from '../../providers/djclient';
|
|
8
|
+
import { labelize } from '../../../utils/form';
|
|
9
|
+
|
|
10
|
+
export const DimensionsSelect = ({ cube }) => {
|
|
11
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
12
|
+
const { values, setFieldValue } = useFormikContext();
|
|
13
|
+
|
|
14
|
+
// eslint-disable-next-line no-unused-vars
|
|
15
|
+
const [field, _, helpers] = useField('dimensions');
|
|
16
|
+
const { setValue } = helpers;
|
|
17
|
+
|
|
18
|
+
// All common dimensions for the selected metrics, grouped by the dimension node and path
|
|
19
|
+
const [allDimensionsOptions, setAllDimensionsOptions] = useState([]);
|
|
20
|
+
|
|
21
|
+
// The selected dimensions, also grouped by dimension node and path
|
|
22
|
+
const [selectedDimensionsByGroup, setSelectedDimensionsByGroup] = useState(
|
|
23
|
+
{},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// The existing cube node's dimensions, if editing a cube
|
|
27
|
+
const [defaultDimensions, setDefaultDimensions] = useState([]);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const fetchData = async () => {
|
|
31
|
+
let cubeDimensions = undefined;
|
|
32
|
+
if (cube) {
|
|
33
|
+
cubeDimensions = cube?.cube_elements
|
|
34
|
+
.filter(element => element.type === 'dimension')
|
|
35
|
+
.map(cubeDim => {
|
|
36
|
+
return {
|
|
37
|
+
value: cubeDim.node_name + '.' + cubeDim.name,
|
|
38
|
+
label:
|
|
39
|
+
labelize(cubeDim.name) +
|
|
40
|
+
(cubeDim.is_primary_key ? ' (PK)' : ''),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
setDefaultDimensions(cubeDimensions);
|
|
44
|
+
setValue(cubeDimensions.map(m => m.value));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (values.metrics && values.metrics.length > 0) {
|
|
48
|
+
// Populate the common dimensions list based on the selected metrics
|
|
49
|
+
const commonDimensions = await djClient.commonDimensions(
|
|
50
|
+
values.metrics,
|
|
51
|
+
);
|
|
52
|
+
const grouped = Object.entries(
|
|
53
|
+
commonDimensions.reduce((group, dimension) => {
|
|
54
|
+
group[dimension.node_name + dimension.path] =
|
|
55
|
+
group[dimension.node_name + dimension.path] ?? [];
|
|
56
|
+
group[dimension.node_name + dimension.path].push(dimension);
|
|
57
|
+
return group;
|
|
58
|
+
}, {}),
|
|
59
|
+
);
|
|
60
|
+
setAllDimensionsOptions(grouped);
|
|
61
|
+
|
|
62
|
+
// Set the selected cube dimensions if an existing cube is being edited
|
|
63
|
+
if (cube) {
|
|
64
|
+
const currentSelectedDimensionsByGroup = selectedDimensionsByGroup;
|
|
65
|
+
grouped.forEach(grouping => {
|
|
66
|
+
const dimensionsInGroup = grouping[1];
|
|
67
|
+
currentSelectedDimensionsByGroup[dimensionsInGroup[0].node_name] =
|
|
68
|
+
getValue(
|
|
69
|
+
cubeDimensions.filter(
|
|
70
|
+
dim =>
|
|
71
|
+
dimensionsInGroup.filter(x => {
|
|
72
|
+
return dim.value === x.name;
|
|
73
|
+
}).length > 0,
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
setSelectedDimensionsByGroup(currentSelectedDimensionsByGroup);
|
|
77
|
+
setValue(Object.values(currentSelectedDimensionsByGroup).flat(2));
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
setAllDimensionsOptions([]);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
fetchData().catch(console.error);
|
|
85
|
+
}, [djClient, setFieldValue, setValue, values.metrics, cube]);
|
|
86
|
+
|
|
87
|
+
// Retrieves the selected values as a list (since it is a multi-select)
|
|
88
|
+
const getValue = options => {
|
|
89
|
+
if (options) {
|
|
90
|
+
return options.map(option => option.value);
|
|
91
|
+
} else {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Builds the block of dimensions selectors, grouped by node name + path
|
|
97
|
+
return allDimensionsOptions.map(grouping => {
|
|
98
|
+
const dimensionsInGroup = grouping[1];
|
|
99
|
+
const groupHeader = (
|
|
100
|
+
<h5
|
|
101
|
+
style={{
|
|
102
|
+
fontWeight: 'normal',
|
|
103
|
+
marginBottom: '5px',
|
|
104
|
+
marginTop: '15px',
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
<a href={`/nodes/${dimensionsInGroup[0].node_name}`}>
|
|
108
|
+
<b>{dimensionsInGroup[0].node_display_name}</b>
|
|
109
|
+
</a>{' '}
|
|
110
|
+
via{' '}
|
|
111
|
+
<span className="HighlightPath">
|
|
112
|
+
{dimensionsInGroup[0].path.join(' → ')}
|
|
113
|
+
</span>
|
|
114
|
+
</h5>
|
|
115
|
+
);
|
|
116
|
+
const dimensionGroupOptions = dimensionsInGroup.map(dim => {
|
|
117
|
+
return {
|
|
118
|
+
value: dim.name,
|
|
119
|
+
label:
|
|
120
|
+
labelize(dim.name.split('.').slice(-1)[0]) +
|
|
121
|
+
(dim.is_primary_key ? ' (PK)' : ''),
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
//
|
|
125
|
+
const cubeDimensions = defaultDimensions.filter(
|
|
126
|
+
dim =>
|
|
127
|
+
dimensionGroupOptions.filter(x => {
|
|
128
|
+
return dim.value === x.value;
|
|
129
|
+
}).length > 0,
|
|
130
|
+
);
|
|
131
|
+
return (
|
|
132
|
+
<>
|
|
133
|
+
{groupHeader}
|
|
134
|
+
<span data-testid={'dimensions-' + dimensionsInGroup[0].node_name}>
|
|
135
|
+
<Select
|
|
136
|
+
className=""
|
|
137
|
+
name={'dimensions-' + dimensionsInGroup[0].node_name}
|
|
138
|
+
defaultValue={cubeDimensions}
|
|
139
|
+
options={dimensionGroupOptions}
|
|
140
|
+
isMulti
|
|
141
|
+
isClearable
|
|
142
|
+
closeMenuOnSelect={false}
|
|
143
|
+
onChange={selected => {
|
|
144
|
+
selectedDimensionsByGroup[dimensionsInGroup[0].node_name] =
|
|
145
|
+
getValue(selected);
|
|
146
|
+
setSelectedDimensionsByGroup(selectedDimensionsByGroup);
|
|
147
|
+
setValue(Object.values(selectedDimensionsByGroup).flat(2));
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
</span>
|
|
151
|
+
</>
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asynchronously loads the component for the Node page
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { lazyLoad } from '../../../utils/loadable';
|
|
7
|
+
|
|
8
|
+
export const CubeBuilderPage = props => {
|
|
9
|
+
return lazyLoad(
|
|
10
|
+
() => import('./index'),
|
|
11
|
+
module => module.CubeBuilderPage,
|
|
12
|
+
{
|
|
13
|
+
fallback: <div></div>,
|
|
14
|
+
},
|
|
15
|
+
)(props);
|
|
16
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A select component for picking metrics.
|
|
3
|
+
*/
|
|
4
|
+
import { useField, useFormikContext } from 'formik';
|
|
5
|
+
import Select from 'react-select';
|
|
6
|
+
import React, { useContext, useEffect, useState } from 'react';
|
|
7
|
+
import DJClientContext from '../../providers/djclient';
|
|
8
|
+
|
|
9
|
+
export const MetricsSelect = ({ cube }) => {
|
|
10
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
11
|
+
const { values } = useFormikContext();
|
|
12
|
+
|
|
13
|
+
// eslint-disable-next-line no-unused-vars
|
|
14
|
+
const [field, _, helpers] = useField('metrics');
|
|
15
|
+
const { setValue } = helpers;
|
|
16
|
+
|
|
17
|
+
// All metrics options
|
|
18
|
+
const [metrics, setMetrics] = useState([]);
|
|
19
|
+
|
|
20
|
+
// The existing cube's metrics, if editing a cube
|
|
21
|
+
const [defaultMetrics, setDefaultMetrics] = useState([]);
|
|
22
|
+
|
|
23
|
+
// Get metrics
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const fetchData = async () => {
|
|
26
|
+
if (cube) {
|
|
27
|
+
const cubeMetrics = cube?.cube_elements
|
|
28
|
+
.filter(element => element.type === 'metric')
|
|
29
|
+
.map(metric => {
|
|
30
|
+
return {
|
|
31
|
+
value: metric.node_name,
|
|
32
|
+
label: metric.node_name,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
setDefaultMetrics(cubeMetrics);
|
|
36
|
+
await setValue(cubeMetrics.map(m => m.value));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const metrics = await djClient.metrics();
|
|
40
|
+
setMetrics(metrics.map(m => ({ value: m, label: m })));
|
|
41
|
+
console.log('metrics', metrics);
|
|
42
|
+
};
|
|
43
|
+
fetchData().catch(console.error);
|
|
44
|
+
}, [djClient, djClient.metrics, cube]);
|
|
45
|
+
|
|
46
|
+
const getValue = options => {
|
|
47
|
+
if (options) {
|
|
48
|
+
return options.map(option => option.value);
|
|
49
|
+
} else {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const render = () => {
|
|
55
|
+
if (
|
|
56
|
+
metrics.length > 0 ||
|
|
57
|
+
(cube !== undefined && defaultMetrics.length > 0 && metrics.length > 0)
|
|
58
|
+
) {
|
|
59
|
+
return (
|
|
60
|
+
<Select
|
|
61
|
+
defaultValue={defaultMetrics}
|
|
62
|
+
options={metrics}
|
|
63
|
+
name="metrics"
|
|
64
|
+
placeholder={`${metrics.length} Available Metrics`}
|
|
65
|
+
onBlur={field.onBlur}
|
|
66
|
+
onChange={selected => {
|
|
67
|
+
setValue(getValue(selected));
|
|
68
|
+
}}
|
|
69
|
+
noOptionsMessage={() => 'No metrics found.'}
|
|
70
|
+
isMulti
|
|
71
|
+
isClearable
|
|
72
|
+
closeMenuOnSelect={false}
|
|
73
|
+
isDisabled={!!(values.metrics.length && values.dimensions.length)}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
return render();
|
|
79
|
+
};
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
2
|
+
import DJClientContext from '../../../providers/djclient';
|
|
3
|
+
import { CubeBuilderPage } from '../index';
|
|
4
|
+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
|
5
|
+
import React from 'react';
|
|
6
|
+
|
|
7
|
+
const mockDjClient = {
|
|
8
|
+
metrics: jest.fn(),
|
|
9
|
+
commonDimensions: jest.fn(),
|
|
10
|
+
createCube: jest.fn(),
|
|
11
|
+
namespaces: jest.fn(),
|
|
12
|
+
cube: jest.fn(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const mockMetrics = [
|
|
16
|
+
'default.num_repair_orders',
|
|
17
|
+
'default.avg_repair_price',
|
|
18
|
+
'default.total_repair_cost',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const mockCube = {
|
|
22
|
+
node_revision_id: 102,
|
|
23
|
+
node_id: 33,
|
|
24
|
+
type: 'cube',
|
|
25
|
+
name: 'default.repair_orders_cube',
|
|
26
|
+
display_name: 'Default: Repair Orders Cube',
|
|
27
|
+
version: 'v4.0',
|
|
28
|
+
description: 'Repairs cube',
|
|
29
|
+
availability: null,
|
|
30
|
+
cube_elements: [
|
|
31
|
+
{
|
|
32
|
+
name: 'default_DOT_total_repair_cost',
|
|
33
|
+
display_name: 'Total Repair Cost',
|
|
34
|
+
node_name: 'default.total_repair_cost',
|
|
35
|
+
type: 'metric',
|
|
36
|
+
partition: null,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'default_DOT_num_repair_orders',
|
|
40
|
+
display_name: 'Num Repair Orders',
|
|
41
|
+
node_name: 'default.num_repair_orders',
|
|
42
|
+
type: 'metric',
|
|
43
|
+
partition: null,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'country',
|
|
47
|
+
display_name: 'Country',
|
|
48
|
+
node_name: 'default.hard_hat',
|
|
49
|
+
type: 'dimension',
|
|
50
|
+
partition: null,
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'state',
|
|
54
|
+
display_name: 'State',
|
|
55
|
+
node_name: 'default.hard_hat',
|
|
56
|
+
type: 'dimension',
|
|
57
|
+
partition: null,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
query: '',
|
|
61
|
+
columns: [
|
|
62
|
+
{
|
|
63
|
+
name: 'default.total_repair_cost',
|
|
64
|
+
display_name: 'Total Repair Cost',
|
|
65
|
+
type: 'double',
|
|
66
|
+
attributes: [],
|
|
67
|
+
dimension: null,
|
|
68
|
+
partition: null,
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'default.num_repair_orders',
|
|
72
|
+
display_name: 'Num Repair Orders',
|
|
73
|
+
type: 'bigint',
|
|
74
|
+
attributes: [],
|
|
75
|
+
dimension: null,
|
|
76
|
+
partition: null,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'default.hard_hat.country',
|
|
80
|
+
display_name: 'Country',
|
|
81
|
+
type: 'string',
|
|
82
|
+
attributes: [],
|
|
83
|
+
dimension: null,
|
|
84
|
+
partition: null,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'default.hard_hat.state',
|
|
88
|
+
display_name: 'State',
|
|
89
|
+
type: 'string',
|
|
90
|
+
attributes: [],
|
|
91
|
+
dimension: null,
|
|
92
|
+
partition: null,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
updated_at: '2023-12-03T06:51:09.598532+00:00',
|
|
96
|
+
materializations: [],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const mockCommonDimensions = [
|
|
100
|
+
{
|
|
101
|
+
name: 'default.date_dim.dateint',
|
|
102
|
+
type: 'timestamp',
|
|
103
|
+
node_name: 'default.date_dim',
|
|
104
|
+
node_display_name: 'Date',
|
|
105
|
+
is_primary_key: false,
|
|
106
|
+
path: [
|
|
107
|
+
'default.repair_order_details.repair_order_id',
|
|
108
|
+
'default.repair_order.hard_hat_id',
|
|
109
|
+
'default.hard_hat.birth_date',
|
|
110
|
+
],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
name: 'default.date_dim.dateint',
|
|
114
|
+
type: 'timestamp',
|
|
115
|
+
node_name: 'default.date_dim',
|
|
116
|
+
node_display_name: 'Date',
|
|
117
|
+
is_primary_key: true,
|
|
118
|
+
path: [
|
|
119
|
+
'default.repair_order_details.repair_order_id',
|
|
120
|
+
'default.repair_order.hard_hat_id',
|
|
121
|
+
'default.hard_hat.hire_date',
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'default.date_dim.day',
|
|
126
|
+
type: 'int',
|
|
127
|
+
node_name: 'default.date_dim',
|
|
128
|
+
node_display_name: 'Date',
|
|
129
|
+
is_primary_key: false,
|
|
130
|
+
path: [
|
|
131
|
+
'default.repair_order_details.repair_order_id',
|
|
132
|
+
'default.repair_order.hard_hat_id',
|
|
133
|
+
'default.hard_hat.birth_date',
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: 'default.date_dim.day',
|
|
138
|
+
type: 'int',
|
|
139
|
+
node_name: 'default.date_dim',
|
|
140
|
+
node_display_name: 'Date',
|
|
141
|
+
is_primary_key: false,
|
|
142
|
+
path: [
|
|
143
|
+
'default.repair_order_details.repair_order_id',
|
|
144
|
+
'default.repair_order.hard_hat_id',
|
|
145
|
+
'default.hard_hat.hire_date',
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
name: 'default.date_dim.month',
|
|
150
|
+
type: 'int',
|
|
151
|
+
node_name: 'default.date_dim',
|
|
152
|
+
node_display_name: 'Date',
|
|
153
|
+
is_primary_key: false,
|
|
154
|
+
path: [
|
|
155
|
+
'default.repair_order_details.repair_order_id',
|
|
156
|
+
'default.repair_order.hard_hat_id',
|
|
157
|
+
'default.hard_hat.birth_date',
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'default.date_dim.month',
|
|
162
|
+
type: 'int',
|
|
163
|
+
node_name: 'default.date_dim',
|
|
164
|
+
node_display_name: 'Date',
|
|
165
|
+
is_primary_key: false,
|
|
166
|
+
path: [
|
|
167
|
+
'default.repair_order_details.repair_order_id',
|
|
168
|
+
'default.repair_order.hard_hat_id',
|
|
169
|
+
'default.hard_hat.hire_date',
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'default.date_dim.year',
|
|
174
|
+
type: 'int',
|
|
175
|
+
node_name: 'default.date_dim',
|
|
176
|
+
node_display_name: 'Date',
|
|
177
|
+
is_primary_key: false,
|
|
178
|
+
path: [
|
|
179
|
+
'default.repair_order_details.repair_order_id',
|
|
180
|
+
'default.repair_order.hard_hat_id',
|
|
181
|
+
'default.hard_hat.birth_date',
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: 'default.date_dim.year',
|
|
186
|
+
type: 'int',
|
|
187
|
+
node_name: 'default.date_dim',
|
|
188
|
+
node_display_name: 'Date',
|
|
189
|
+
is_primary_key: false,
|
|
190
|
+
path: [
|
|
191
|
+
'default.repair_order_details.repair_order_id',
|
|
192
|
+
'default.repair_order.hard_hat_id',
|
|
193
|
+
'default.hard_hat.hire_date',
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
describe('CubeBuilderPage', () => {
|
|
199
|
+
beforeEach(() => {
|
|
200
|
+
mockDjClient.metrics.mockResolvedValue(mockMetrics);
|
|
201
|
+
mockDjClient.commonDimensions.mockResolvedValue(mockCommonDimensions);
|
|
202
|
+
mockDjClient.createCube.mockResolvedValue({ status: 201, json: {} });
|
|
203
|
+
mockDjClient.namespaces.mockResolvedValue(['default']);
|
|
204
|
+
mockDjClient.cube.mockResolvedValue(mockCube);
|
|
205
|
+
|
|
206
|
+
window.scrollTo = jest.fn();
|
|
207
|
+
|
|
208
|
+
render(
|
|
209
|
+
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
210
|
+
<CubeBuilderPage />
|
|
211
|
+
</DJClientContext.Provider>,
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
jest.clearAllMocks();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('renders without crashing', () => {
|
|
220
|
+
expect(screen.getByText('Cube')).toBeInTheDocument();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('renders the Metrics section', () => {
|
|
224
|
+
expect(screen.getByText('Metrics *')).toBeInTheDocument();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('renders the Dimensions section', () => {
|
|
228
|
+
expect(screen.getByText('Dimensions *')).toBeInTheDocument();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('creates a new cube', async () => {
|
|
232
|
+
render(
|
|
233
|
+
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
234
|
+
<CubeBuilderPage />
|
|
235
|
+
</DJClientContext.Provider>,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await waitFor(() => {
|
|
239
|
+
expect(mockDjClient.metrics).toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const selectMetrics = screen.getAllByTestId('select-metrics')[0];
|
|
243
|
+
expect(selectMetrics).toBeDefined();
|
|
244
|
+
expect(selectMetrics).not.toBeNull();
|
|
245
|
+
expect(screen.getAllByText('3 Available Metrics')[0]).toBeInTheDocument();
|
|
246
|
+
|
|
247
|
+
fireEvent.keyDown(selectMetrics.firstChild, { key: 'ArrowDown' });
|
|
248
|
+
for (const metric of mockMetrics) {
|
|
249
|
+
await waitFor(() => {
|
|
250
|
+
expect(screen.getByText(metric)).toBeInTheDocument();
|
|
251
|
+
fireEvent.click(screen.getByText(metric));
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
fireEvent.click(screen.getAllByText('Dimensions *')[0]);
|
|
255
|
+
|
|
256
|
+
expect(mockDjClient.commonDimensions).toHaveBeenCalled();
|
|
257
|
+
|
|
258
|
+
const selectDimensions = screen.getAllByTestId('select-dimensions')[0];
|
|
259
|
+
expect(selectDimensions).toBeDefined();
|
|
260
|
+
expect(selectDimensions).not.toBeNull();
|
|
261
|
+
expect(
|
|
262
|
+
screen.getByText(
|
|
263
|
+
'default.repair_order_details.repair_order_id → default.repair_order.hard_hat_id → default.hard_hat.birth_date',
|
|
264
|
+
),
|
|
265
|
+
).toBeInTheDocument();
|
|
266
|
+
|
|
267
|
+
const selectDimensionsDate = screen.getAllByTestId(
|
|
268
|
+
'dimensions-default.date_dim',
|
|
269
|
+
)[0];
|
|
270
|
+
|
|
271
|
+
fireEvent.keyDown(selectDimensionsDate.firstChild, { key: 'ArrowDown' });
|
|
272
|
+
fireEvent.click(screen.getByText('Day'));
|
|
273
|
+
fireEvent.click(screen.getByText('Month'));
|
|
274
|
+
fireEvent.click(screen.getByText('Year'));
|
|
275
|
+
fireEvent.click(screen.getByText('Dateint'));
|
|
276
|
+
|
|
277
|
+
// Save
|
|
278
|
+
const createCube = screen.getAllByRole('button', {
|
|
279
|
+
name: 'CreateCube',
|
|
280
|
+
})[0];
|
|
281
|
+
expect(createCube).toBeInTheDocument();
|
|
282
|
+
|
|
283
|
+
await waitFor(() => {
|
|
284
|
+
fireEvent.click(createCube);
|
|
285
|
+
});
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(mockDjClient.createCube).toHaveBeenCalledWith(
|
|
288
|
+
'',
|
|
289
|
+
'',
|
|
290
|
+
'',
|
|
291
|
+
'draft',
|
|
292
|
+
[
|
|
293
|
+
'default.num_repair_orders',
|
|
294
|
+
'default.avg_repair_price',
|
|
295
|
+
'default.total_repair_cost',
|
|
296
|
+
],
|
|
297
|
+
[
|
|
298
|
+
'default.date_dim.day',
|
|
299
|
+
'default.date_dim.month',
|
|
300
|
+
'default.date_dim.year',
|
|
301
|
+
'default.date_dim.dateint',
|
|
302
|
+
],
|
|
303
|
+
[],
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const renderEditNode = element => {
|
|
309
|
+
return render(
|
|
310
|
+
<MemoryRouter initialEntries={['/nodes/default.repair_orders_cube/edit']}>
|
|
311
|
+
<Routes>
|
|
312
|
+
<Route path="nodes/:name/edit" element={element} />
|
|
313
|
+
</Routes>
|
|
314
|
+
</MemoryRouter>,
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
it('updates an existing cube', async () => {
|
|
319
|
+
renderEditNode(
|
|
320
|
+
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
321
|
+
<CubeBuilderPage />
|
|
322
|
+
</DJClientContext.Provider>,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
await waitFor(() => {
|
|
326
|
+
expect(mockDjClient.metrics).toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const selectMetrics = screen.getAllByTestId('select-metrics')[0];
|
|
330
|
+
expect(selectMetrics).toBeDefined();
|
|
331
|
+
expect(selectMetrics).not.toBeNull();
|
|
332
|
+
expect(screen.getAllByText('3 Available Metrics')[0]).toBeInTheDocument();
|
|
333
|
+
|
|
334
|
+
fireEvent.click(screen.getAllByText('Dimensions *')[0]);
|
|
335
|
+
|
|
336
|
+
expect(mockDjClient.commonDimensions).toHaveBeenCalled();
|
|
337
|
+
|
|
338
|
+
const selectDimensions = screen.getAllByTestId('select-dimensions')[0];
|
|
339
|
+
expect(selectDimensions).toBeDefined();
|
|
340
|
+
expect(selectDimensions).not.toBeNull();
|
|
341
|
+
expect(
|
|
342
|
+
screen.getByText(
|
|
343
|
+
'default.repair_order_details.repair_order_id → default.repair_order.hard_hat_id → default.hard_hat.birth_date',
|
|
344
|
+
),
|
|
345
|
+
).toBeInTheDocument();
|
|
346
|
+
|
|
347
|
+
const selectDimensionsDate = screen.getAllByTestId(
|
|
348
|
+
'dimensions-default.date_dim',
|
|
349
|
+
)[0];
|
|
350
|
+
|
|
351
|
+
fireEvent.keyDown(selectDimensionsDate.firstChild, { key: 'ArrowDown' });
|
|
352
|
+
fireEvent.click(screen.getByText('Day'));
|
|
353
|
+
fireEvent.click(screen.getByText('Month'));
|
|
354
|
+
fireEvent.click(screen.getByText('Year'));
|
|
355
|
+
fireEvent.click(screen.getByText('Dateint'));
|
|
356
|
+
|
|
357
|
+
// Save
|
|
358
|
+
const createCube = screen.getAllByRole('button', {
|
|
359
|
+
name: 'CreateCube',
|
|
360
|
+
})[0];
|
|
361
|
+
expect(createCube).toBeInTheDocument();
|
|
362
|
+
|
|
363
|
+
await waitFor(() => {
|
|
364
|
+
fireEvent.click(createCube);
|
|
365
|
+
});
|
|
366
|
+
await waitFor(() => {
|
|
367
|
+
expect(mockDjClient.createCube).toHaveBeenCalledWith(
|
|
368
|
+
'',
|
|
369
|
+
'',
|
|
370
|
+
'',
|
|
371
|
+
'draft',
|
|
372
|
+
[],
|
|
373
|
+
[],
|
|
374
|
+
[],
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import React, { useContext, useEffect, useState } from 'react';
|
|
2
|
+
import NamespaceHeader from '../../components/NamespaceHeader';
|
|
3
|
+
import { DataJunctionAPI } from '../../services/DJService';
|
|
4
|
+
import DJClientContext from '../../providers/djclient';
|
|
5
|
+
import 'react-querybuilder/dist/query-builder.scss';
|
|
6
|
+
import 'styles/styles.scss';
|
|
7
|
+
import { ErrorMessage, Field, Form, Formik } from 'formik';
|
|
8
|
+
import { displayMessageAfterSubmit } from '../../../utils/form';
|
|
9
|
+
import { useParams } from 'react-router-dom';
|
|
10
|
+
import { Action } from '../../components/forms/Action';
|
|
11
|
+
import NodeNameField from '../../components/forms/NodeNameField';
|
|
12
|
+
import NodeTagsInput from '../../components/forms/NodeTagsInput';
|
|
13
|
+
import { MetricsSelect } from './MetricsSelect';
|
|
14
|
+
import { DimensionsSelect } from './DimensionsSelect';
|
|
15
|
+
|
|
16
|
+
export function CubeBuilderPage() {
|
|
17
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
18
|
+
|
|
19
|
+
let { nodeType, initialNamespace, name } = useParams();
|
|
20
|
+
const action = name !== undefined ? Action.Edit : Action.Add;
|
|
21
|
+
const validator = ruleType => !!ruleType.value;
|
|
22
|
+
|
|
23
|
+
const initialValues = {
|
|
24
|
+
name: action === Action.Edit ? name : '',
|
|
25
|
+
namespace: action === Action.Add ? initialNamespace : '',
|
|
26
|
+
display_name: '',
|
|
27
|
+
description: '',
|
|
28
|
+
mode: 'draft',
|
|
29
|
+
metrics: [],
|
|
30
|
+
dimensions: [],
|
|
31
|
+
filters: [],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const handleSubmit = (values, { setSubmitting, setStatus }) => {
|
|
35
|
+
if (action === Action.Add) {
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
createNode(values, setStatus);
|
|
38
|
+
setSubmitting(false);
|
|
39
|
+
}, 400);
|
|
40
|
+
} else {
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
patchNode(values, setStatus);
|
|
43
|
+
setSubmitting(false);
|
|
44
|
+
}, 400);
|
|
45
|
+
}
|
|
46
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const createNode = async (values, setStatus) => {
|
|
50
|
+
const { status, json } = await djClient.createCube(
|
|
51
|
+
values.name,
|
|
52
|
+
values.display_name,
|
|
53
|
+
values.description,
|
|
54
|
+
values.mode,
|
|
55
|
+
values.metrics,
|
|
56
|
+
values.dimensions,
|
|
57
|
+
values.filters || [],
|
|
58
|
+
);
|
|
59
|
+
if (status === 200 || status === 201) {
|
|
60
|
+
if (values.tags) {
|
|
61
|
+
await djClient.tagsNode(values.name, values.tags);
|
|
62
|
+
}
|
|
63
|
+
setStatus({
|
|
64
|
+
success: (
|
|
65
|
+
<>
|
|
66
|
+
Successfully created {json.type} node{' '}
|
|
67
|
+
<a href={`/nodes/${json.name}`}>{json.name}</a>!
|
|
68
|
+
</>
|
|
69
|
+
),
|
|
70
|
+
});
|
|
71
|
+
} else {
|
|
72
|
+
setStatus({
|
|
73
|
+
failure: `${json.message}`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const patchNode = async (values, setStatus) => {
|
|
79
|
+
const { status, json } = await djClient.patchCube(
|
|
80
|
+
values.name,
|
|
81
|
+
values.display_name,
|
|
82
|
+
values.description,
|
|
83
|
+
values.mode,
|
|
84
|
+
values.metrics,
|
|
85
|
+
values.dimensions,
|
|
86
|
+
values.filters || [],
|
|
87
|
+
);
|
|
88
|
+
const tagsResponse = await djClient.tagsNode(
|
|
89
|
+
values.name,
|
|
90
|
+
values.tags.map(tag => tag),
|
|
91
|
+
);
|
|
92
|
+
if ((status === 200 || status === 201) && tagsResponse.status === 200) {
|
|
93
|
+
setStatus({
|
|
94
|
+
success: (
|
|
95
|
+
<>
|
|
96
|
+
Successfully updated {json.type} node{' '}
|
|
97
|
+
<a href={`/nodes/${json.name}`}>{json.name}</a>!
|
|
98
|
+
</>
|
|
99
|
+
),
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
102
|
+
setStatus({
|
|
103
|
+
failure: `${json.message}`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const updateFieldsWithNodeData = (data, setFieldValue) => {
|
|
109
|
+
setFieldValue('display_name', data.display_name || '', false);
|
|
110
|
+
setFieldValue('description', data.description || '', false);
|
|
111
|
+
setFieldValue('mode', data.mode || 'draft', false);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const staticFieldsInEdit = () => (
|
|
115
|
+
<>
|
|
116
|
+
<div className="NodeNameInput NodeCreationInput">
|
|
117
|
+
<label htmlFor="name">Name</label> {name}
|
|
118
|
+
</div>
|
|
119
|
+
<div className="NodeNameInput NodeCreationInput">
|
|
120
|
+
<label htmlFor="name">Type</label> cube
|
|
121
|
+
</div>
|
|
122
|
+
<div className="DisplayNameInput NodeCreationInput">
|
|
123
|
+
<ErrorMessage name="display_name" component="span" />
|
|
124
|
+
<label htmlFor="displayName">Display Name</label>
|
|
125
|
+
<Field
|
|
126
|
+
type="text"
|
|
127
|
+
name="display_name"
|
|
128
|
+
id="displayName"
|
|
129
|
+
placeholder="Human readable display name"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
</>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// @ts-ignore
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
<div className="mid">
|
|
139
|
+
<NamespaceHeader namespace="" />
|
|
140
|
+
<Formik
|
|
141
|
+
initialValues={initialValues}
|
|
142
|
+
validate={validator}
|
|
143
|
+
onSubmit={handleSubmit}
|
|
144
|
+
>
|
|
145
|
+
{function Render({ isSubmitting, status, setFieldValue, props }) {
|
|
146
|
+
const [node, setNode] = useState([]);
|
|
147
|
+
|
|
148
|
+
// Get cube
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const fetchData = async () => {
|
|
151
|
+
if (name) {
|
|
152
|
+
const node = await djClient.node(name);
|
|
153
|
+
const cube = await djClient.cube(name);
|
|
154
|
+
cube.tags = node.tags;
|
|
155
|
+
setNode(cube);
|
|
156
|
+
updateFieldsWithNodeData(cube, setFieldValue);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
fetchData().catch(console.error);
|
|
160
|
+
}, [djClient, djClient.metrics, name]);
|
|
161
|
+
|
|
162
|
+
return (
|
|
163
|
+
<Form>
|
|
164
|
+
<div className="card">
|
|
165
|
+
<div className="card-header">
|
|
166
|
+
<h2>
|
|
167
|
+
{action === Action.Edit ? 'Edit' : 'Create'}{' '}
|
|
168
|
+
<span
|
|
169
|
+
className={`node_type__cube node_type_creation_heading`}
|
|
170
|
+
>
|
|
171
|
+
Cube
|
|
172
|
+
</span>
|
|
173
|
+
</h2>
|
|
174
|
+
{displayMessageAfterSubmit(status)}
|
|
175
|
+
{action === Action.Add ? (
|
|
176
|
+
<NodeNameField />
|
|
177
|
+
) : (
|
|
178
|
+
staticFieldsInEdit(node)
|
|
179
|
+
)}
|
|
180
|
+
<div className="DescriptionInput NodeCreationInput">
|
|
181
|
+
<ErrorMessage name="description" component="span" />
|
|
182
|
+
<label htmlFor="Description">Description</label>
|
|
183
|
+
<Field
|
|
184
|
+
type="textarea"
|
|
185
|
+
as="textarea"
|
|
186
|
+
name="description"
|
|
187
|
+
id="Description"
|
|
188
|
+
placeholder="Describe your node"
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
<div className="CubeCreationInput">
|
|
192
|
+
<label htmlFor="react-select-3-input">Metrics *</label>
|
|
193
|
+
<p>Select metrics to include in the cube.</p>
|
|
194
|
+
<span
|
|
195
|
+
data-testid="select-metrics"
|
|
196
|
+
style={{ marginTop: '15px' }}
|
|
197
|
+
>
|
|
198
|
+
{action === Action.Edit ? (
|
|
199
|
+
<MetricsSelect cube={node} />
|
|
200
|
+
) : (
|
|
201
|
+
<MetricsSelect />
|
|
202
|
+
)}
|
|
203
|
+
</span>
|
|
204
|
+
</div>
|
|
205
|
+
<br />
|
|
206
|
+
<br />
|
|
207
|
+
<div className="CubeCreationInput">
|
|
208
|
+
<label htmlFor="react-select-3-input">Dimensions *</label>
|
|
209
|
+
<p>
|
|
210
|
+
Select dimensions to include in the cube. As metrics are
|
|
211
|
+
selected above, the list of available dimensions will be
|
|
212
|
+
filtered to those shared by the selected metrics. If the
|
|
213
|
+
dimensions list is empty, no shared dimensions were
|
|
214
|
+
discovered.
|
|
215
|
+
</p>
|
|
216
|
+
<span data-testid="select-dimensions">
|
|
217
|
+
{action === Action.Edit ? (
|
|
218
|
+
<DimensionsSelect cube={node} />
|
|
219
|
+
) : (
|
|
220
|
+
<DimensionsSelect />
|
|
221
|
+
)}
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
<div className="NodeModeInput NodeCreationInput">
|
|
225
|
+
<ErrorMessage name="mode" component="span" />
|
|
226
|
+
<label htmlFor="Mode">Mode</label>
|
|
227
|
+
<Field as="select" name="mode" id="Mode">
|
|
228
|
+
<option value="draft">Draft</option>
|
|
229
|
+
<option value="published">Published</option>
|
|
230
|
+
</Field>
|
|
231
|
+
</div>
|
|
232
|
+
<NodeTagsInput action={action} node={node} />
|
|
233
|
+
<button
|
|
234
|
+
type="submit"
|
|
235
|
+
disabled={isSubmitting}
|
|
236
|
+
aria-label="CreateCube"
|
|
237
|
+
>
|
|
238
|
+
{action === Action.Add ? 'Create Cube' : 'Save'}{' '}
|
|
239
|
+
{nodeType}
|
|
240
|
+
</button>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</Form>
|
|
244
|
+
);
|
|
245
|
+
}}
|
|
246
|
+
</Formik>
|
|
247
|
+
</div>
|
|
248
|
+
</>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
CubeBuilderPage.defaultProps = {
|
|
253
|
+
djClient: DataJunctionAPI,
|
|
254
|
+
};
|
|
@@ -76,7 +76,12 @@ export default function NodeInfoTab({ node }) {
|
|
|
76
76
|
aria-label="CubeElement"
|
|
77
77
|
aria-hidden="false"
|
|
78
78
|
>
|
|
79
|
-
<a href={`/nodes/${cubeElem.node_name}`}>
|
|
79
|
+
<a href={`/nodes/${cubeElem.node_name}`}>
|
|
80
|
+
{cubeElem.type === 'dimension'
|
|
81
|
+
? labelize(cubeElem.node_name.split('.').slice(-1)[0]) + ' → '
|
|
82
|
+
: ''}
|
|
83
|
+
{cubeElem.display_name}
|
|
84
|
+
</a>
|
|
80
85
|
<span
|
|
81
86
|
className={`badge node_type__${
|
|
82
87
|
cubeElem.type === 'metric' ? cubeElem.type : 'dimension'
|
|
@@ -146,6 +146,61 @@ export const DataJunctionAPI = {
|
|
|
146
146
|
}
|
|
147
147
|
},
|
|
148
148
|
|
|
149
|
+
createCube: async function (
|
|
150
|
+
name,
|
|
151
|
+
display_name,
|
|
152
|
+
description,
|
|
153
|
+
mode,
|
|
154
|
+
metrics,
|
|
155
|
+
dimensions,
|
|
156
|
+
filters,
|
|
157
|
+
) {
|
|
158
|
+
const response = await fetch(`${DJ_URL}/nodes/cube`, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'Content-Type': 'application/json',
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
name: name,
|
|
165
|
+
display_name: display_name,
|
|
166
|
+
description: description,
|
|
167
|
+
metrics: metrics,
|
|
168
|
+
dimensions: dimensions,
|
|
169
|
+
filters: filters,
|
|
170
|
+
mode: mode,
|
|
171
|
+
}),
|
|
172
|
+
credentials: 'include',
|
|
173
|
+
});
|
|
174
|
+
return { status: response.status, json: await response.json() };
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
patchCube: async function (
|
|
178
|
+
name,
|
|
179
|
+
display_name,
|
|
180
|
+
description,
|
|
181
|
+
mode,
|
|
182
|
+
metrics,
|
|
183
|
+
dimensions,
|
|
184
|
+
filters,
|
|
185
|
+
) {
|
|
186
|
+
const response = await fetch(`${DJ_URL}/nodes/${name}`, {
|
|
187
|
+
method: 'PATCH',
|
|
188
|
+
headers: {
|
|
189
|
+
'Content-Type': 'application/json',
|
|
190
|
+
},
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
display_name: display_name,
|
|
193
|
+
description: description,
|
|
194
|
+
metrics: metrics,
|
|
195
|
+
dimensions: dimensions,
|
|
196
|
+
filters: filters || [],
|
|
197
|
+
mode: mode,
|
|
198
|
+
}),
|
|
199
|
+
credentials: 'include',
|
|
200
|
+
});
|
|
201
|
+
return { status: response.status, json: await response.json() };
|
|
202
|
+
},
|
|
203
|
+
|
|
149
204
|
registerTable: async function (catalog, schema, table) {
|
|
150
205
|
const response = await fetch(
|
|
151
206
|
`${DJ_URL}/register/table/${catalog}/${schema}/${table}`,
|
|
@@ -140,6 +140,73 @@ describe('DataJunctionAPI', () => {
|
|
|
140
140
|
});
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
+
it('calls createCube correctly', async () => {
|
|
144
|
+
const sampleArgs = [
|
|
145
|
+
'default.node_name',
|
|
146
|
+
'Node Display Name',
|
|
147
|
+
'Some readable description',
|
|
148
|
+
'draft',
|
|
149
|
+
['default.num_repair_orders'],
|
|
150
|
+
[
|
|
151
|
+
'default.date_dim.year',
|
|
152
|
+
'default.date_dim.month',
|
|
153
|
+
'default.date_dim.day',
|
|
154
|
+
],
|
|
155
|
+
[],
|
|
156
|
+
];
|
|
157
|
+
fetch.mockResponseOnce(JSON.stringify({}));
|
|
158
|
+
await DataJunctionAPI.createCube(...sampleArgs);
|
|
159
|
+
expect(fetch).toHaveBeenCalledWith(`${DJ_URL}/nodes/cube`, {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: {
|
|
162
|
+
'Content-Type': 'application/json',
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
name: sampleArgs[0],
|
|
166
|
+
display_name: sampleArgs[1],
|
|
167
|
+
description: sampleArgs[2],
|
|
168
|
+
metrics: sampleArgs[4],
|
|
169
|
+
dimensions: sampleArgs[5],
|
|
170
|
+
filters: sampleArgs[6],
|
|
171
|
+
mode: sampleArgs[3],
|
|
172
|
+
}),
|
|
173
|
+
credentials: 'include',
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('calls patchCube correctly', async () => {
|
|
178
|
+
const sampleArgs = [
|
|
179
|
+
'default.node_name',
|
|
180
|
+
'Node Display Name',
|
|
181
|
+
'Some readable description',
|
|
182
|
+
'draft',
|
|
183
|
+
['default.num_repair_orders'],
|
|
184
|
+
[
|
|
185
|
+
'default.date_dim.year',
|
|
186
|
+
'default.date_dim.month',
|
|
187
|
+
'default.date_dim.day',
|
|
188
|
+
],
|
|
189
|
+
[],
|
|
190
|
+
];
|
|
191
|
+
fetch.mockResponseOnce(JSON.stringify({}));
|
|
192
|
+
await DataJunctionAPI.patchCube(...sampleArgs);
|
|
193
|
+
expect(fetch).toHaveBeenCalledWith(`${DJ_URL}/nodes/default.node_name`, {
|
|
194
|
+
method: 'PATCH',
|
|
195
|
+
headers: {
|
|
196
|
+
'Content-Type': 'application/json',
|
|
197
|
+
},
|
|
198
|
+
body: JSON.stringify({
|
|
199
|
+
display_name: sampleArgs[1],
|
|
200
|
+
description: sampleArgs[2],
|
|
201
|
+
metrics: sampleArgs[4],
|
|
202
|
+
dimensions: sampleArgs[5],
|
|
203
|
+
filters: sampleArgs[6],
|
|
204
|
+
mode: sampleArgs[3],
|
|
205
|
+
}),
|
|
206
|
+
credentials: 'include',
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
143
210
|
it('calls upstreams correctly', async () => {
|
|
144
211
|
const nodeName = 'sampleNode';
|
|
145
212
|
fetch.mockResponseOnce(JSON.stringify({}));
|
package/src/styles/index.css
CHANGED
|
@@ -39,6 +39,13 @@ form {
|
|
|
39
39
|
padding: 0 20px;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
.CubeCreationInput {
|
|
43
|
+
margin: 0.5rem 0;
|
|
44
|
+
display: inline-grid;
|
|
45
|
+
width: 75%;
|
|
46
|
+
padding: 0 20px;
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
.DisplayNameInput,
|
|
43
50
|
.FullNameInput,
|
|
44
51
|
.NamespaceInput {
|
|
@@ -195,3 +202,8 @@ form {
|
|
|
195
202
|
width: 20%;
|
|
196
203
|
padding: 0 20px;
|
|
197
204
|
}
|
|
205
|
+
|
|
206
|
+
.HighlightPath {
|
|
207
|
+
background: #f5efff;
|
|
208
|
+
padding: 5px;
|
|
209
|
+
}
|