datajunction-ui 0.0.1-a35.dev0 → 0.0.1-a37

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.
Files changed (25) hide show
  1. package/package.json +1 -1
  2. package/src/app/components/forms/Action.jsx +8 -0
  3. package/src/app/components/forms/NodeNameField.jsx +64 -0
  4. package/src/app/components/forms/NodeTagsInput.jsx +61 -0
  5. package/src/app/index.tsx +18 -0
  6. package/src/app/pages/AddEditNodePage/__tests__/AddEditNodePageFormFailed.test.jsx +1 -1
  7. package/src/app/pages/AddEditNodePage/__tests__/AddEditNodePageFormSuccess.test.jsx +2 -5
  8. package/src/app/pages/AddEditNodePage/index.jsx +16 -7
  9. package/src/app/pages/CubeBuilderPage/DimensionsSelect.jsx +154 -0
  10. package/src/app/pages/CubeBuilderPage/Loadable.jsx +16 -0
  11. package/src/app/pages/CubeBuilderPage/MetricsSelect.jsx +79 -0
  12. package/src/app/pages/CubeBuilderPage/__tests__/index.test.jsx +405 -0
  13. package/src/app/pages/CubeBuilderPage/index.jsx +254 -0
  14. package/src/app/pages/NamespacePage/index.jsx +5 -0
  15. package/src/app/pages/NodePage/AddBackfillPopover.jsx +13 -6
  16. package/src/app/pages/NodePage/AddMaterializationPopover.jsx +47 -24
  17. package/src/app/pages/NodePage/NodeInfoTab.jsx +6 -1
  18. package/src/app/pages/NodePage/NodeMaterializationTab.jsx +44 -32
  19. package/src/app/pages/NodePage/__tests__/AddBackfillPopover.test.jsx +0 -1
  20. package/src/app/pages/NodePage/__tests__/AddMaterializationPopover.test.jsx +84 -0
  21. package/src/app/pages/NodePage/__tests__/__snapshots__/NodePage.test.jsx.snap +78 -174
  22. package/src/app/services/DJService.js +66 -12
  23. package/src/app/services/__tests__/DJService.test.jsx +72 -7
  24. package/src/styles/index.css +4 -0
  25. package/src/styles/node-creation.scss +12 -0
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.1a37",
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' },
@@ -113,22 +113,19 @@ describe('AddEditNodePage submission succeeded', () => {
113
113
  'Number of repair orders!!!',
114
114
  'SELECT count(repair_order_id) default_DOT_num_repair_orders FROM default.repair_orders',
115
115
  'published',
116
- ['repair_order_id', 'country'],
116
+ null,
117
117
  'neutral',
118
118
  'unitless',
119
119
  );
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(
127
127
  1,
128
128
  );
129
- expect(
130
- await screen.getByDisplayValue('repair_order_id, country'),
131
- ).toBeInTheDocument();
132
129
  expect(
133
130
  await screen.getByText(/Successfully updated metric node/),
134
131
  ).toBeInTheDocument();
@@ -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;
@@ -201,13 +202,18 @@ export function AddEditNodePage() {
201
202
  'mode',
202
203
  'tags',
203
204
  ];
205
+ const primaryKey = data.columns
206
+ .filter(
207
+ col =>
208
+ col.attributes &&
209
+ col.attributes.filter(
210
+ attr => attr.attribute_type.name === 'primary_key',
211
+ ).length > 0,
212
+ )
213
+ .map(col => col.name);
204
214
  fields.forEach(field => {
205
- if (
206
- field === 'primary_key' &&
207
- data[field] !== undefined &&
208
- Array.isArray(data[field])
209
- ) {
210
- data[field] = data[field].join(', ');
215
+ if (field === 'primary_key') {
216
+ setFieldValue(field, primaryKey.join(', '));
211
217
  } else {
212
218
  setFieldValue(field, data[field] || '', false);
213
219
  }
@@ -363,6 +369,9 @@ export function AddEditNodePage() {
363
369
  // Check if node type can be edited
364
370
  if (!nodeCanBeEdited(data.type)) {
365
371
  setNode(null);
372
+ if (data.type === 'cube') {
373
+ navigate(`/nodes/${data.name}/edit-cube`);
374
+ }
366
375
  setMessage(
367
376
  `Node ${name} is of type ${data.type} and cannot be edited`,
368
377
  );
@@ -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 && 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
+ };