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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.1-a35.dev0",
3
+ "version": "0.0.1-a36",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -0,0 +1,8 @@
1
+ export class Action {
2
+ static Add = new Action('add');
3
+ static Edit = new Action('edit');
4
+
5
+ constructor(name) {
6
+ this.name = name;
7
+ }
8
+ }
@@ -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
+ };
@@ -150,6 +150,11 @@ export function NamespacePage() {
150
150
  Tag
151
151
  </div>
152
152
  </a>
153
+ <a href={`/create/cube/${namespace}`}>
154
+ <div className="node_type__cube node_type_creation_heading">
155
+ Cube
156
+ </div>
157
+ </a>
153
158
  </div>
154
159
  </div>
155
160
  </span>
@@ -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}`}>{cubeElem.display_name}</a>
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'
@@ -36,7 +36,6 @@ describe('<AddBackfillPopover />', () => {
36
36
  fireEvent.click(getByLabelText('AddBackfill'));
37
37
 
38
38
  fireEvent.click(getByText('Save'));
39
- getByText('Save').click();
40
39
 
41
40
  // Expect setAttributes to be called
42
41
  await waitFor(() => {
@@ -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({}));
@@ -543,6 +543,10 @@ tbody th {
543
543
  font-size: 12px;
544
544
  }
545
545
 
546
+ .dimension_option_subheading:empty {
547
+ display: none;
548
+ }
549
+
546
550
  .upstreams {
547
551
  width: 260px;
548
552
  display: flex;
@@ -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
+ }