datajunction-ui 0.0.1-a85 → 0.0.1-a86.dev0

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.1a85",
3
+ "version": "0.0.1-a86.dev0",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -1,14 +1,14 @@
1
1
  import '../../styles/loading.css';
2
2
 
3
- export default function LoadingIcon() {
4
- return (
5
- <center>
6
- <div className="lds-ring">
7
- <div></div>
8
- <div></div>
9
- <div></div>
10
- <div></div>
11
- </div>
12
- </center>
3
+ export default function LoadingIcon({ centered = true }) {
4
+ const content = (
5
+ <div className="lds-ring">
6
+ <div></div>
7
+ <div></div>
8
+ <div></div>
9
+ <div></div>
10
+ </div>
13
11
  );
12
+
13
+ return centered ? <center>{content}</center> : content;
14
14
  }
@@ -0,0 +1,36 @@
1
+ const WrenchIcon = props => (
2
+ <svg
3
+ width="16"
4
+ height="17"
5
+ viewBox="0 0 16 17"
6
+ fill="none"
7
+ xmlns="http://www.w3.org/2000/svg"
8
+ >
9
+ <g clip-path="url(#clip0_24_77)">
10
+ <rect
11
+ width="16"
12
+ height="16"
13
+ transform="translate(0 0.649803)"
14
+ fill="white"
15
+ />
16
+ <path
17
+ fill-rule="evenodd"
18
+ clip-rule="evenodd"
19
+ d="M6.41665 5.44985C6.41665 2.8265 8.5433 0.699852 11.1667 0.699852C11.4613 0.699852 11.7504 0.726773 12.0313 0.778484C12.5762 0.878798 12.9364 1.28101 13.0406 1.74377C13.1406 2.18774 13.0066 2.67055 12.6619 3.01531L11.2273 4.44985L12.1667 5.38919L13.6012 3.95465C13.946 3.60989 14.4288 3.47589 14.8728 3.57591C15.3355 3.68017 15.7377 4.04033 15.838 4.58525C15.8898 4.86615 15.9167 5.15519 15.9167 5.44985C15.9167 8.0732 13.79 10.1999 11.1667 10.1999C10.658 10.1999 10.167 10.1196 9.70625 9.97091L3.50172 16.1754C2.94847 16.7287 2.05149 16.7287 1.49825 16.1754L0.441055 15.1183C-0.112189 14.565 -0.112186 13.668 0.441056 13.1148L6.64559 6.91025C6.49685 6.44951 6.41665 5.9585 6.41665 5.44985ZM11.1667 2.19985C9.37172 2.19985 7.91665 3.65492 7.91665 5.44985C7.91665 5.92812 8.01948 6.38034 8.20352 6.7873L8.41725 7.25991L8.05048 7.62667L1.56064 14.1165L2.49998 15.0559L8.98982 8.56601L9.35659 8.19925L9.8292 8.41298C10.2362 8.59702 10.6884 8.69985 11.1667 8.69985C12.9616 8.69985 14.4167 7.24477 14.4167 5.44985C14.4167 5.38796 14.415 5.32654 14.4116 5.26562L12.697 6.98018L12.1667 7.51051L11.6363 6.98018L9.63632 4.98018L9.10599 4.44985L9.63632 3.91952L11.3509 2.20496C11.29 2.20157 11.2286 2.19985 11.1667 2.19985Z"
20
+ fill="black"
21
+ fill-opacity="0.9"
22
+ />
23
+ </g>
24
+ <defs>
25
+ <clipPath id="clip0_24_77">
26
+ <rect
27
+ width="16"
28
+ height="16"
29
+ fill="white"
30
+ transform="translate(0 0.649803)"
31
+ />
32
+ </clipPath>
33
+ </defs>
34
+ </svg>
35
+ );
36
+ export default WrenchIcon;
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Primary key select component
2
+ * Component for arbitrary column metadata
3
3
  */
4
4
  import { ErrorMessage, useFormikContext } from 'formik';
5
5
  import { useContext, useMemo, useState } from 'react';
6
6
  import DJClientContext from '../../providers/djclient';
7
7
  import { FormikSelect } from './FormikSelect';
8
8
 
9
- export const PrimaryKeySelect = ({ defaultValue }) => {
9
+ export const ColumnMetadata = ({ name, label, defaultValue }) => {
10
10
  const djClient = useContext(DJClientContext).DataJunctionAPI;
11
11
 
12
12
  // Used to pull out current form values for node validation
@@ -20,8 +20,7 @@ export const PrimaryKeySelect = ({ defaultValue }) => {
20
20
  }
21
21
  }, [availableColumns]);
22
22
 
23
- // When focus is on the primary key field, refresh the list of available
24
- // primary key columns for selection
23
+ // When focus is on the input field, refresh the list of available columns for selection
25
24
  const refreshColumns = event => {
26
25
  async function fetchData() {
27
26
  // eslint-disable-next-line no-unused-vars
@@ -42,18 +41,19 @@ export const PrimaryKeySelect = ({ defaultValue }) => {
42
41
  };
43
42
 
44
43
  return (
45
- <div className="CubeCreationInput">
46
- <ErrorMessage name="primary_key" component="span" />
47
- <label htmlFor="react-select-3-input">Primary Key</label>
48
- <span data-testid="select-primary-key">
44
+ <div className="NodeCreationInput" style={{ width: '25%' }}>
45
+ <ErrorMessage name={name} component="span" />
46
+ <label htmlFor="react-select-3-input">{label}</label>
47
+ <span data-testid={`select-${name}`}>
49
48
  <FormikSelect
50
- className="MultiSelectInput"
49
+ className="SelectInput"
50
+ style={{ width: '100px' }}
51
51
  defaultValue={defaultValue}
52
52
  selectOptions={selectableOptions}
53
- formikFieldName="primary_key"
54
- placeholder="Choose Primary Key"
53
+ formikFieldName={name}
54
+ // placeholder={`Choose ${label}`}
55
55
  onFocus={event => refreshColumns(event)}
56
- isMulti={true}
56
+ isMulti={false}
57
57
  />
58
58
  </span>
59
59
  </div>
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Column metadata configuration component
3
+ */
4
+ import { ErrorMessage, useFormikContext } from 'formik';
5
+ import { useContext, useMemo, useState, useEffect } from 'react';
6
+ import DJClientContext from '../../providers/djclient';
7
+ import { FormikSelect } from './FormikSelect';
8
+ import WrenchIcon from 'app/icons/WrenchIcon';
9
+
10
+ export const ColumnsMetadataInput = ({ columns }) => {
11
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
12
+
13
+ // Used to pull out current form values for node validation
14
+ const { values } = useFormikContext();
15
+
16
+ // The available columns, determined from validating the node query
17
+ const [availableColumns, setAvailableColumns] = useState([]);
18
+ const selectableOptions = useMemo(() => {
19
+ if (availableColumns && availableColumns.length > 0) {
20
+ return availableColumns;
21
+ }
22
+ }, [availableColumns]);
23
+
24
+ // When focus is on the primary key field, refresh the list of available
25
+ // primary key columns for selection
26
+ useEffect(() => {
27
+ const fetchData = async () => {
28
+ // eslint-disable-next-line no-unused-vars
29
+ const { status, json } = await djClient.validateNode(
30
+ values.type,
31
+ values.name,
32
+ values.display_name,
33
+ values.description,
34
+ values.query,
35
+ );
36
+ setAvailableColumns(
37
+ json.columns.map(col => {
38
+ return { value: col.name, label: col.name };
39
+ }),
40
+ );
41
+ };
42
+ fetchData().catch(console.error);
43
+ }, [djClient, name]);
44
+
45
+ return (
46
+ <div
47
+ className="ColumnsMetadataInput NodeCreationInput"
48
+ style={{ border: 'dashed 1px #ccc', padding: '20px' }}
49
+ >
50
+ <ErrorMessage name="mode" component="span" />
51
+ <label htmlFor="Mode">Columns Metadata</label>
52
+ <table>
53
+ <thead>
54
+ <tr>
55
+ <th>Column</th>
56
+ <th>Metadata</th>
57
+ </tr>
58
+ </thead>
59
+ <tbody>
60
+ {availableColumns.map(col => (
61
+ <tr key={col.value}>
62
+ <td>{col.value}</td>
63
+ <td>
64
+ <WrenchIcon />
65
+ </td>
66
+ </tr>
67
+ ))}
68
+ </tbody>
69
+ </table>
70
+ </div>
71
+ );
72
+ };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Component for selecting node columns based on the current form state
3
+ */
4
+ import { ErrorMessage, useFormikContext } from 'formik';
5
+ import { useContext, useMemo, useState, useEffect } from 'react';
6
+ import DJClientContext from '../../providers/djclient';
7
+ import { FormikSelect } from './FormikSelect';
8
+
9
+ export const ColumnsSelect = ({ defaultValue, fieldName, label, isMulti = false }) => {
10
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
11
+
12
+ // Used to pull out current form values for node validation
13
+ const { values } = useFormikContext();
14
+
15
+ // The available columns, determined from validating the node query
16
+ const [availableColumns, setAvailableColumns] = useState([]);
17
+ const selectableOptions = useMemo(() => {
18
+ if (availableColumns && availableColumns.length > 0) {
19
+ return availableColumns;
20
+ }
21
+ }, [availableColumns]);
22
+
23
+ // Fetch columns by validating the latest node query
24
+ const fetchColumns = async () => {
25
+ try {
26
+ const { status, json } = await djClient.validateNode(
27
+ values.type,
28
+ values.name,
29
+ values.display_name,
30
+ values.description,
31
+ values.query,
32
+ );
33
+ if (json?.columns) {
34
+ setAvailableColumns(
35
+ json.columns.map(col => ({ value: col.name, label: col.name }))
36
+ );
37
+ }
38
+ } catch (error) {
39
+ console.error('Error fetching columns:', error);
40
+ }
41
+ };
42
+
43
+ useEffect(() => {
44
+ fetchColumns();
45
+ }, [values.type, values.name, values.query]);
46
+
47
+ return selectableOptions === undefined ? '' : (
48
+ <div className="CubeCreationInput">
49
+ <ErrorMessage name={fieldName} component="span" />
50
+ <label htmlFor="react-select-3-input">{label}</label>
51
+ <span data-testid={`select-${fieldName}`}>
52
+ <FormikSelect
53
+ className={isMulti ? "MultiSelectInput" : "SelectInput"}
54
+ defaultValue={isMulti ? defaultValue.map(val => {return {
55
+ value: val,
56
+ label: val,
57
+ }}) : {value: defaultValue,
58
+ label: defaultValue,}}
59
+ selectOptions={selectableOptions}
60
+ formikFieldName={fieldName}
61
+ onFocus={event => fetchColumns(event)}
62
+ isMulti={isMulti}
63
+ />
64
+ </span>
65
+ </div>
66
+ );
67
+ };
@@ -3,8 +3,7 @@
3
3
  * node types is largely the same, with minor differences handled server-side. For the `query`
4
4
  * field, this page will render a CodeMirror SQL editor with autocompletion and syntax highlighting.
5
5
  */
6
- import { ErrorMessage, Field, Form, Formik } from 'formik';
7
-
6
+ import { ErrorMessage, Form, Formik } from 'formik';
8
7
  import NamespaceHeader from '../../components/NamespaceHeader';
9
8
  import { useContext, useEffect, useState } from 'react';
10
9
  import DJClientContext from '../../providers/djclient';
@@ -13,7 +12,6 @@ import { useParams, useNavigate } from 'react-router-dom';
13
12
  import { FullNameField } from './FullNameField';
14
13
  import { MetricQueryField } from './MetricQueryField';
15
14
  import { displayMessageAfterSubmit } from '../../../utils/form';
16
- import { PrimaryKeySelect } from './PrimaryKeySelect';
17
15
  import { NodeQueryField } from './NodeQueryField';
18
16
  import { MetricMetadataFields } from './MetricMetadataFields';
19
17
  import { UpstreamNodeField } from './UpstreamNodeField';
@@ -25,6 +23,7 @@ import { DescriptionField } from './DescriptionField';
25
23
  import { NodeModeField } from './NodeModeField';
26
24
  import { RequiredDimensionsSelect } from './RequiredDimensionsSelect';
27
25
  import LoadingIcon from '../../icons/LoadingIcon';
26
+ import { ColumnsSelect } from './ColumnsSelect';
28
27
 
29
28
  class Action {
30
29
  static Add = new Action('add');
@@ -35,7 +34,7 @@ class Action {
35
34
  }
36
35
  }
37
36
 
38
- export function AddEditNodePage() {
37
+ export function AddEditNodePage({ extensions = {} }) {
39
38
  const djClient = useContext(DJClientContext).DataJunctionAPI;
40
39
  const navigate = useNavigate();
41
40
 
@@ -77,6 +76,7 @@ export function AddEditNodePage() {
77
76
  });
78
77
  }
79
78
  };
79
+ const submitHandlers = [handleSubmit];
80
80
 
81
81
  const pageTitle =
82
82
  action === Action.Add ? (
@@ -288,10 +288,11 @@ export function AddEditNodePage() {
288
288
  />,
289
289
  );
290
290
  setSelectPrimaryKey(
291
- <PrimaryKeySelect
292
- defaultValue={primaryKey.map(col => {
293
- return { value: col, label: col };
294
- })}
291
+ <ColumnsSelect
292
+ defaultValue={primaryKey}
293
+ fieldName="primary_key"
294
+ label="Primary Key"
295
+ isMulti={true}
295
296
  />,
296
297
  );
297
298
  setSelectRequiredDims(
@@ -329,7 +330,17 @@ export function AddEditNodePage() {
329
330
  <Formik
330
331
  initialValues={initialValues}
331
332
  validate={validator}
332
- onSubmit={handleSubmit}
333
+ onSubmit={
334
+ (values, { setSubmitting, setStatus }) => {
335
+ try {
336
+ submitHandlers.map(handler => handler(values, { setSubmitting, setStatus }));
337
+ } catch (error) {
338
+ console.error("Error in submission", error);
339
+ } finally {
340
+ setSubmitting(false);
341
+ }
342
+ }
343
+ }
333
344
  >
334
345
  {function Render({ isSubmitting, status, setFieldValue }) {
335
346
  const [node, setNode] = useState([]);
@@ -407,13 +418,22 @@ export function AddEditNodePage() {
407
418
  action === Action.Edit ? (
408
419
  selectPrimaryKey
409
420
  ) : (
410
- <PrimaryKeySelect />
421
+ <ColumnsSelect />
411
422
  )
412
423
  ) : action === Action.Edit ? (
413
424
  selectRequiredDims
414
425
  ) : (
415
426
  <RequiredDimensionsSelect />
416
427
  )}
428
+ {Object.entries(extensions).map(([key, ExtensionComponent]) => (
429
+ <div key={key} className="mt-4 border-t pt-4">
430
+ <ExtensionComponent
431
+ node={node}
432
+ action={action}
433
+ registerSubmitHandler={onSubmit => submitHandlers.indexOf(onSubmit) === -1 ? submitHandlers.push(onSubmit) : null}
434
+ />
435
+ </div>
436
+ ))}
417
437
  {action === Action.Edit ? selectTags : <TagsField />}
418
438
  <NodeModeField />
419
439
 
@@ -12,6 +12,8 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
12
12
  const [options, setOptions] = useState([]);
13
13
  const [jobs, setJobs] = useState([]);
14
14
 
15
+ const timePartitionColumns = node.columns.filter(col => col.partition);
16
+
15
17
  const ref = useRef(null);
16
18
 
17
19
  useEffect(() => {
@@ -42,13 +44,23 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
42
44
  if (!values.job_type) {
43
45
  values.job_type = 'spark_sql';
44
46
  }
45
- const { status, json } = await djClient.materialize(
46
- values.node,
47
- values.job_type,
48
- values.strategy,
49
- values.schedule,
50
- config,
51
- );
47
+ const { status, json } = (
48
+ values.job_type === 'druid_cube' ?
49
+ await djClient.materializeCube(
50
+ values.node,
51
+ values.job_type,
52
+ values.strategy,
53
+ values.schedule,
54
+ values.lookback_window,
55
+ ) :
56
+ await djClient.materialize(
57
+ values.node,
58
+ values.job_type,
59
+ values.strategy,
60
+ values.schedule,
61
+ config,
62
+ )
63
+ );
52
64
  if (status === 200 || status === 201) {
53
65
  setStatus({ success: json.message });
54
66
  window.location.reload();
@@ -99,8 +111,8 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
99
111
  initialValues={{
100
112
  node: node?.name,
101
113
  job_type:
102
- node?.type === 'cube' ? 'druid_metrics_cube' : 'spark_sql',
103
- strategy: 'full',
114
+ node?.type === 'cube' ? 'druid_cube' : 'spark_sql',
115
+ strategy: timePartitionColumns.length == 1 ? 'incremental_time' : 'full',
104
116
  schedule: '@daily',
105
117
  lookback_window: '1 DAY',
106
118
  spark_config: {
@@ -122,19 +134,10 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
122
134
  <Field as="select" name="job_type">
123
135
  <>
124
136
  <option
125
- key={'druid_measures_cube'}
126
- value={'druid_measures_cube'}
127
- >
128
- Druid Measures Cube (Pre-Agg Cube)
129
- </option>
130
- <option
131
- key={'druid_metrics_cube'}
132
- value={'druid_metrics_cube'}
137
+ key={'druid_cube'}
138
+ value={'druid_cube'}
133
139
  >
134
- Druid Metrics Cube (Post-Agg Cube)
135
- </option>
136
- <option key={'spark_sql'} value={'spark_sql'}>
137
- Iceberg Table
140
+ Druid
138
141
  </option>
139
142
  </>
140
143
  </Field>
@@ -150,6 +153,7 @@ export default function AddMaterializationPopover({ node, onSubmit }) {
150
153
  value={node?.name}
151
154
  readOnly={true}
152
155
  />
156
+ {console.log('timePartitionColumns.length', timePartitionColumns.length)}
153
157
  <span data-testid="edit-partition">
154
158
  <label htmlFor="strategy">Strategy</label>
155
159
  <Field as="select" name="strategy">
@@ -0,0 +1,141 @@
1
+ import { useContext, useEffect, useRef, useState } from 'react';
2
+ import * as React from 'react';
3
+ import DJClientContext from '../../providers/djclient';
4
+ import { Field, Form, Formik } from 'formik';
5
+ import { FormikSelect } from '../AddEditNodePage/FormikSelect';
6
+ import EditIcon from '../../icons/EditIcon';
7
+ import { displayMessageAfterSubmit } from '../../../utils/form';
8
+ import LoadingIcon from '../../icons/LoadingIcon';
9
+ import { MetricQueryField } from '../AddEditNodePage/MetricQueryField';
10
+
11
+ export default function LinkComplexDimensionPopover({ link, onSubmit }) {
12
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
13
+ const [popoverAnchor, setPopoverAnchor] = useState(false);
14
+ const ref = useRef(null);
15
+
16
+ useEffect(() => {
17
+ const handleClickOutside = event => {
18
+ if (ref.current && !ref.current.contains(event.target)) {
19
+ setPopoverAnchor(false);
20
+ }
21
+ };
22
+ document.addEventListener('click', handleClickOutside, true);
23
+ return () => {
24
+ document.removeEventListener('click', handleClickOutside, true);
25
+ };
26
+ }, [setPopoverAnchor]);
27
+
28
+ const handleSubmit = async (
29
+ { node, column, dimension },
30
+ { setSubmitting, setStatus },
31
+ ) => {
32
+ if (referencedDimensionNode && dimension === 'Remove') {
33
+ await unlinkDimension(
34
+ node,
35
+ column,
36
+ referencedDimensionNode,
37
+ setStatus,
38
+ ).then(_ => setSubmitting(false));
39
+ } else {
40
+ await linkDimension(node, column, dimension, setStatus).then(_ =>
41
+ setSubmitting(false),
42
+ );
43
+ }
44
+ onSubmit();
45
+ };
46
+
47
+ const linkDimension = async (node, column, dimension, setStatus) => {
48
+ const response = await djClient.linkDimension(node, column, dimension);
49
+ if (response.status === 200 || response.status === 201) {
50
+ setStatus({ success: 'Saved!' });
51
+ } else {
52
+ setStatus({
53
+ failure: `${response.json.message}`,
54
+ });
55
+ }
56
+ };
57
+
58
+ const unlinkDimension = async (node, column, currentDimension, setStatus) => {
59
+ const response = await djClient.unlinkDimension(
60
+ node,
61
+ column,
62
+ currentDimension,
63
+ );
64
+ if (response.status === 200 || response.status === 201) {
65
+ setStatus({ success: 'Removed dimension link!' });
66
+ } else {
67
+ setStatus({
68
+ failure: `${response.json.message}`,
69
+ });
70
+ }
71
+ };
72
+
73
+ return (
74
+ <>
75
+ <button
76
+ className="edit_button"
77
+ aria-label="LinkDimension"
78
+ tabIndex="0"
79
+ onClick={() => {
80
+ setPopoverAnchor(!popoverAnchor);
81
+ }}
82
+ >
83
+ <EditIcon />
84
+ </button>
85
+ <div
86
+ className="popover"
87
+ role="dialog"
88
+ aria-label="client-code"
89
+ style={{ display: popoverAnchor === false ? 'none' : 'block' }}
90
+ ref={ref}
91
+ >
92
+ <Formik
93
+ initialValues={
94
+ {
95
+ // column: column.name,
96
+ // node: node.name,
97
+ // dimension: '',
98
+ // currentDimension: referencedDimensionNode,
99
+ }
100
+ }
101
+ onSubmit={handleSubmit}
102
+ >
103
+ {function Render({ isSubmitting, status, setFieldValue }) {
104
+ return (
105
+ <Form>
106
+ {displayMessageAfterSubmit(status)}
107
+ <span data-testid="link-dimension">
108
+ <label>Join Type</label>
109
+ <FormikSelect
110
+ selectOptions={[{ value: 'left', label: 'LEFT' }]}
111
+ formikFieldName="join_type"
112
+ placeholder="Select join type"
113
+ className="join_type"
114
+ defaultValue={link.join_type || ''}
115
+ isMulti={false}
116
+ />
117
+ {console.log('link, ', link)}
118
+
119
+ <label>Join On</label>
120
+ <MetricQueryField
121
+ djClient={djClient}
122
+ value={link.join_sql ? link.join_sql : ''}
123
+ />
124
+ </span>
125
+ <button
126
+ className="add_node"
127
+ type="submit"
128
+ aria-label="SaveComplexDimension"
129
+ aria-hidden="false"
130
+ disabled={isSubmitting}
131
+ >
132
+ {isSubmitting ? <LoadingIcon /> : 'Save'}
133
+ </button>
134
+ </Form>
135
+ );
136
+ }}
137
+ </Formik>
138
+ </div>
139
+ </>
140
+ );
141
+ }
@@ -34,24 +34,24 @@ export default function NodeDependenciesTab({ node, djClient }) {
34
34
  {retrieved ? (
35
35
  <NodeList nodes={nodeDAG.upstreams} />
36
36
  ) : (
37
- <span style={{ display: 'inline-block' }}>
38
- <LoadingIcon />
37
+ <span style={{ display: 'block' }}>
38
+ <LoadingIcon centered={false} />
39
39
  </span>
40
40
  )}
41
41
  <h2>Downstreams</h2>
42
42
  {retrieved ? (
43
43
  <NodeList nodes={nodeDAG.downstreams} />
44
44
  ) : (
45
- <span style={{ display: 'inline-block' }}>
46
- <LoadingIcon />
45
+ <span style={{ display: 'block' }}>
46
+ <LoadingIcon centered={false} />
47
47
  </span>
48
48
  )}
49
49
  <h2>Dimensions</h2>
50
50
  {retrieved ? (
51
51
  <NodeDimensionsList rawDimensions={nodeDAG.dimensions} />
52
52
  ) : (
53
- <span style={{ display: 'inline-block' }}>
54
- <LoadingIcon />
53
+ <span style={{ display: 'block' }}>
54
+ <LoadingIcon centered={false} />
55
55
  </span>
56
56
  )}
57
57
  </div>
@@ -142,6 +142,6 @@ export function NodeList({ nodes }) {
142
142
  ))}
143
143
  </ul>
144
144
  ) : (
145
- <span style={{ display: 'inline-block' }}>None</span>
145
+ <span style={{ display: 'block' }}>None</span>
146
146
  );
147
147
  }
@@ -70,7 +70,7 @@ export default function NodeInfoTab({ node }) {
70
70
  );
71
71
 
72
72
  const metricQueryDiv =
73
- node.type === 'metric' ? (
73
+ node?.type === 'metric' ? (
74
74
  <div className="list-group-item d-flex">
75
75
  <div className="gap-2 w-100 justify-content-between py-3">
76
76
  <div style={{ marginBottom: '30px' }}>
@@ -980,6 +980,25 @@ export const DataJunctionAPI = {
980
980
  );
981
981
  return { status: response.status, json: await response.json() };
982
982
  },
983
+ materializeCube: async function (nodeName, jobType, strategy, schedule, lookbackWindow) {
984
+ const response = await fetch(
985
+ `${DJ_URL}/nodes/${nodeName}/materialization`,
986
+ {
987
+ method: 'POST',
988
+ headers: {
989
+ 'Content-Type': 'application/json',
990
+ },
991
+ body: JSON.stringify({
992
+ job: jobType,
993
+ strategy: strategy,
994
+ schedule: schedule,
995
+ lookback_window: lookbackWindow,
996
+ }),
997
+ credentials: 'include',
998
+ },
999
+ );
1000
+ return { status: response.status, json: await response.json() };
1001
+ },
983
1002
  runBackfill: async function (nodeName, materializationName, partitionValues) {
984
1003
  const response = await fetch(
985
1004
  `${DJ_URL}/nodes/${nodeName}/materializations/${materializationName}/backfill`,