datajunction-ui 0.0.1-a48 → 0.0.1-a49.dev2

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.1a48",
3
+ "version": "0.0.1-a49.dev2",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -164,7 +164,7 @@
164
164
  ],
165
165
  "coverageThreshold": {
166
166
  "global": {
167
- "statements": 89,
167
+ "statements": 87,
168
168
  "branches": 75,
169
169
  "lines": 80,
170
170
  "functions": 85
@@ -1,3 +1,7 @@
1
+ import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
2
+ import { solarizedDark } from 'react-syntax-highlighter/src/styles/hljs';
3
+ import React from 'react';
4
+
1
5
  export default function QueryInfo({
2
6
  id,
3
7
  state,
@@ -8,9 +12,11 @@ export default function QueryInfo({
8
12
  output_table,
9
13
  scheduled,
10
14
  started,
15
+ finished,
11
16
  numRows,
17
+ isList = false,
12
18
  }) {
13
- return (
19
+ return isList === false ? (
14
20
  <div className="table-responsive">
15
21
  <table className="card-inner-table table">
16
22
  <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
@@ -73,5 +79,94 @@ export default function QueryInfo({
73
79
  </tbody>
74
80
  </table>
75
81
  </div>
82
+ ) : (
83
+ <div className="rightbottom">
84
+ <ul style={{ padding: '20px' }}>
85
+ <li className={'query-info'}>
86
+ <label>Query ID</label>{' '}
87
+ <span className="tag_value rounded-pill badge">
88
+ {links?.length ? (
89
+ <a
90
+ href={links[links.length - 1]}
91
+ target={'_blank'}
92
+ rel="noreferrer"
93
+ >
94
+ {id}
95
+ </a>
96
+ ) : (
97
+ id
98
+ )}
99
+ </span>
100
+ </li>
101
+ <li className={'query-info'}>
102
+ <label>State</label>
103
+ <span className="tag_value rounded-pill badge">{state}</span>
104
+ </li>
105
+ <li className={'query-info'}>
106
+ <label>Engine</label>{' '}
107
+ <span className="tag_value rounded-pill badge">
108
+ {engine_name}
109
+ {' - '}
110
+ {engine_version}
111
+ </span>
112
+ </li>
113
+ <li className={'query-info'}>
114
+ <label>Scheduled</label> {scheduled}
115
+ </li>
116
+ <li className={'query-info'}>
117
+ <label>Started</label> {started}
118
+ </li>
119
+ <li className={'query-info'}>
120
+ <label>Finished</label> {finished}
121
+ </li>
122
+ <li className={'query-info'}>
123
+ <label>Logs</label>{' '}
124
+ {errors?.length ? (
125
+ errors.map(error => (
126
+ <div
127
+ style={{
128
+ height: '800px',
129
+ width: '80%',
130
+ overflow: 'scroll',
131
+ borderRadius: '0',
132
+ border: '1px solid #ccc',
133
+ }}
134
+ className="queryrunner-query"
135
+ >
136
+ <SyntaxHighlighter
137
+ language="javascript"
138
+ style={solarizedDark}
139
+ wrapLines={true}
140
+ >
141
+ {error}
142
+ </SyntaxHighlighter>
143
+ </div>
144
+ ))
145
+ ) : (
146
+ <></>
147
+ )}
148
+ </li>
149
+ <li className={'query-info'}>
150
+ <label>Links:</label>{' '}
151
+ {links?.length ? (
152
+ links.map((link, idx) => (
153
+ <p key={idx}>
154
+ <a href={link} target="_blank" rel="noreferrer">
155
+ {link}
156
+ </a>
157
+ </p>
158
+ ))
159
+ ) : (
160
+ <></>
161
+ )}
162
+ </li>
163
+ <li className={'query-info'}>
164
+ <label>Output Table:</label> {output_table}
165
+ </li>
166
+ <li className={'query-info'}>
167
+ <label>Rows:</label> {numRows}
168
+ </li>
169
+ </ul>
170
+ </div>
76
171
  );
77
172
  }
@@ -3,7 +3,7 @@ import '../../styles/loading.css';
3
3
  export default function LoadingIcon() {
4
4
  return (
5
5
  <center>
6
- <div class="lds-ring">
6
+ <div className="lds-ring">
7
7
  <div></div>
8
8
  <div></div>
9
9
  <div></div>
@@ -0,0 +1,86 @@
1
+ import { useContext, useEffect, useState } from 'react';
2
+ import * as React from 'react';
3
+ import DJClientContext from '../../providers/djclient';
4
+ import CreatableSelect from 'react-select/creatable';
5
+
6
+ export default function DimensionFilter({ dimension, onChange }) {
7
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
8
+ const [dimensionValues, setDimensionValues] = useState([]);
9
+
10
+ useEffect(() => {
11
+ const fetchData = async () => {
12
+ const dimensionNode = await djClient.node(dimension.metadata.node_name);
13
+
14
+ // Only include the primary keys as filterable dimensions for now, until we figure out how
15
+ // to build a manageable UI experience around all dimensional attributes
16
+ if (dimensionNode && dimensionNode.type === 'dimension') {
17
+ const primaryKey =
18
+ dimensionNode.name +
19
+ '.' +
20
+ dimensionNode.columns
21
+ .filter(col =>
22
+ col.attributes.some(
23
+ attr => attr.attribute_type.name === 'primary_key',
24
+ ),
25
+ )
26
+ .map(col => col.name);
27
+ const label =
28
+ dimensionNode.name +
29
+ '.' +
30
+ dimensionNode.columns
31
+ .filter(col =>
32
+ col.attributes.some(attr => attr.attribute_type.name === 'label'),
33
+ )
34
+ .map(col => col.name);
35
+
36
+ const data = await djClient.nodeData(dimension.metadata.node_name);
37
+ /* istanbul ignore if */
38
+ if (dimensionNode && data.results && data.results.length > 0) {
39
+ const columnNames = data.results[0].columns.map(
40
+ column => column.semantic_entity,
41
+ );
42
+ const dimValues = data.results[0].rows.map(row => {
43
+ const rowData = { value: '', label: '' };
44
+ row.forEach((value, index) => {
45
+ if (columnNames[index] === primaryKey) {
46
+ rowData.value = value;
47
+ if (rowData.label === '') {
48
+ rowData.label = value;
49
+ }
50
+ } else if (columnNames[index] === label) {
51
+ rowData.label = value;
52
+ }
53
+ });
54
+ return rowData;
55
+ });
56
+ setDimensionValues(dimValues);
57
+ }
58
+ }
59
+ };
60
+ fetchData().catch(console.error);
61
+ }, [dimension.metadata.node_name, djClient]);
62
+
63
+ return (
64
+ <div key={dimension.metadata.node_name}>
65
+ {dimension.label.split('[').slice(0)[0]} (
66
+ {
67
+ <a href={`/nodes/${dimension.metadata.node_name}`}>
68
+ {dimension.metadata.node_display_name}
69
+ </a>
70
+ }
71
+ )
72
+ <CreatableSelect
73
+ name="dimensions"
74
+ options={dimensionValues}
75
+ isMulti
76
+ isClearable
77
+ onChange={event => {
78
+ onChange({
79
+ dimension: dimension.value,
80
+ values: event,
81
+ });
82
+ }}
83
+ />
84
+ </div>
85
+ );
86
+ }
@@ -1,10 +1,11 @@
1
1
  import { useContext, useEffect, useRef, useState } from 'react';
2
2
  import * as React from 'react';
3
3
  import DJClientContext from '../../providers/djclient';
4
- import { Form, Formik } from 'formik';
4
+ import { Field, Form, Formik } from 'formik';
5
5
  import { FormikSelect } from '../AddEditNodePage/FormikSelect';
6
6
  import EditIcon from '../../icons/EditIcon';
7
7
  import { displayMessageAfterSubmit } from '../../../utils/form';
8
+ import LoadingIcon from '../../icons/LoadingIcon';
8
9
 
9
10
  export default function LinkDimensionPopover({
10
11
  column,
@@ -35,11 +36,17 @@ export default function LinkDimensionPopover({
35
36
  { node, column, dimension },
36
37
  { setSubmitting, setStatus },
37
38
  ) => {
38
- setSubmitting(false);
39
39
  if (referencedDimensionNode && dimension === 'Remove') {
40
- await unlinkDimension(node, column, referencedDimensionNode, setStatus);
40
+ await unlinkDimension(
41
+ node,
42
+ column,
43
+ referencedDimensionNode,
44
+ setStatus,
45
+ ).then(_ => setSubmitting(false));
41
46
  } else {
42
- await linkDimension(node, column, dimension, setStatus);
47
+ await linkDimension(node, column, dimension, setStatus).then(_ =>
48
+ setSubmitting(false),
49
+ );
43
50
  }
44
51
  onSubmit();
45
52
  };
@@ -137,8 +144,9 @@ export default function LinkDimensionPopover({
137
144
  type="submit"
138
145
  aria-label="SaveLinkDimension"
139
146
  aria-hidden="false"
147
+ disabled={isSubmitting}
140
148
  >
141
- Save
149
+ {isSubmitting ? <LoadingIcon /> : 'Save'}
142
150
  </button>
143
151
  </Form>
144
152
  );
@@ -1,5 +1,4 @@
1
1
  import { useEffect, useState } from 'react';
2
- import ClientCodePopover from './ClientCodePopover';
3
2
  import * as React from 'react';
4
3
  import EditColumnPopover from './EditColumnPopover';
5
4
  import LinkDimensionPopover from './LinkDimensionPopover';
@@ -35,8 +34,11 @@ export default function NodeColumnTab({ node, djClient }) {
35
34
  useEffect(() => {
36
35
  const fetchData = async () => {
37
36
  const dimensions = await djClient.dimensions();
38
- const options = dimensions.map(name => {
39
- return { value: name, label: name };
37
+ const options = dimensions.map(dim => {
38
+ return {
39
+ value: dim.name,
40
+ label: `${dim.name} (${dim.indegree} links)`,
41
+ };
40
42
  });
41
43
  setDimensions(options);
42
44
  };
@@ -74,6 +74,17 @@ export default function NodeHistory({ node, djClient }) {
74
74
  </div>
75
75
  );
76
76
  }
77
+ if (event.activity_type === 'refresh') {
78
+ return (
79
+ <div className="history-left">
80
+ <b style={{ textTransform: 'capitalize' }}>{event.activity_type}</b>{' '}
81
+ {event.entity_type}{' '}
82
+ <b>
83
+ <a href={'/nodes/' + event.entity_name}>{event.entity_name}</a>
84
+ </b>
85
+ </div>
86
+ );
87
+ }
77
88
  if (event.activity_type === 'update' && event.entity_type === 'node') {
78
89
  return (
79
90
  <div className="history-left">
@@ -0,0 +1,367 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import Select from 'react-select';
3
+ import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
4
+ import { foundation } from 'react-syntax-highlighter/src/styles/hljs';
5
+ import { labelize } from '../../../utils/form';
6
+ import { Form, Formik } from 'formik';
7
+ import DimensionFilter from './DimensionFilter';
8
+ import QueryInfo from '../../components/QueryInfo';
9
+ import LoadingIcon from '../../icons/LoadingIcon';
10
+
11
+ export default function NodeValidateTab({ node, djClient }) {
12
+ const [query, setQuery] = useState('');
13
+ const [lookup, setLookup] = useState([]);
14
+ const [running, setRunning] = useState(false);
15
+
16
+ // These are a list of dimensions that are available for this node
17
+ const [dimensions, setDimensions] = useState([]);
18
+
19
+ // A separate structure used to store the selected dimensions to filter by and their values
20
+ const [selectedFilters, setSelectedFilters] = useState({});
21
+
22
+ // The set of dimensions and filters to pass to the /sql or /data endpoints for the node
23
+ const [selection, setSelection] = useState({
24
+ dimensions: [],
25
+ filters: [],
26
+ });
27
+
28
+ // Any query result info retrieved when a node query is run
29
+ const [queryInfo, setQueryInfo] = useState(null);
30
+
31
+ const initialValues = {};
32
+
33
+ const [state, setState] = useState({
34
+ selectedTab: 'results',
35
+ });
36
+
37
+ const switchTab = tabName => {
38
+ setState({ selectedTab: tabName });
39
+ };
40
+
41
+ useEffect(() => {
42
+ const fetchData = async () => {
43
+ if (node) {
44
+ // Find all the dimensions for this node
45
+ const dimensions = await djClient.nodeDimensions(node.name);
46
+
47
+ // Create a dimensions lookup object
48
+ const lookup = {};
49
+ dimensions.forEach(dimension => {
50
+ lookup[dimension.name] = dimension;
51
+ });
52
+ setLookup(lookup);
53
+
54
+ // Group the dimensions by dimension node
55
+ const grouped = Object.entries(
56
+ dimensions.reduce((group, dimension) => {
57
+ group[dimension.node_name + dimension.path] =
58
+ group[dimension.node_name + dimension.path] ?? [];
59
+ group[dimension.node_name + dimension.path].push(dimension);
60
+ return group;
61
+ }, {}),
62
+ );
63
+ setDimensions(grouped);
64
+
65
+ // Build the query for this node based on the user-provided dimensions and filters
66
+ const query = await djClient.sql(node.name, selection);
67
+ setQuery(query.sql);
68
+ }
69
+ };
70
+ fetchData().catch(console.error);
71
+ }, [djClient, node, selection]);
72
+
73
+ const dimensionsList = dimensions.flatMap(grouping => {
74
+ const dimensionsInGroup = grouping[1];
75
+ return dimensionsInGroup
76
+ .filter(dim => dim.is_primary_key === true)
77
+ .map(dim => {
78
+ return {
79
+ value: dim.name,
80
+ label: (
81
+ <span>
82
+ {labelize(dim.name.split('.').slice(-1)[0])}{' '}
83
+ <small>{dim.name}</small>
84
+ </span>
85
+ ),
86
+ };
87
+ });
88
+ });
89
+
90
+ // Run the query and use SSE to stream the status of the query execution results
91
+ const runQuery = async (values, setStatus, setSubmitting) => {
92
+ setRunning(true);
93
+ const sse = await djClient.streamNodeData(node?.name, selection);
94
+ sse.onmessage = e => {
95
+ const messageData = JSON.parse(JSON.parse(e.data));
96
+ if (
97
+ messageData !== null &&
98
+ messageData?.state !== 'FINISHED' &&
99
+ messageData?.state !== 'CANCELED' &&
100
+ messageData?.state !== 'FAILED'
101
+ ) {
102
+ setRunning(false);
103
+ }
104
+ if (messageData.results && messageData.results?.length > 0) {
105
+ messageData.numRows = messageData.results?.length
106
+ ? messageData.results[0].rows.length
107
+ : [];
108
+ switchTab('results');
109
+ setRunning(false);
110
+ } else {
111
+ switchTab('info');
112
+ }
113
+ setQueryInfo(messageData);
114
+ };
115
+ sse.onerror = () => sse.close();
116
+ };
117
+
118
+ // Handle form submission (runs the query)
119
+ const handleSubmit = async (values, { setSubmitting, setStatus }) => {
120
+ await runQuery(values, setStatus, setSubmitting);
121
+ };
122
+
123
+ // Handle when filter values are updated. This is available for all nodes.
124
+ const handleAddFilters = event => {
125
+ const updatedFilters = selectedFilters;
126
+ if (event.dimension in updatedFilters) {
127
+ updatedFilters[event.dimension].operator = event.operator;
128
+ updatedFilters[event.dimension].values = event.values;
129
+ } else {
130
+ updatedFilters[event.dimension] = {
131
+ operator: event.operator,
132
+ values: event.values,
133
+ };
134
+ }
135
+ setSelectedFilters(updatedFilters);
136
+ const updatedDimensions = selection.dimensions.concat([event.dimension]);
137
+ setSelection({
138
+ filters: Object.entries(updatedFilters).map(obj =>
139
+ obj[1].values
140
+ ? `${obj[0]} IN (${obj[1].values
141
+ .map(val =>
142
+ ['int', 'bigint', 'float', 'double', 'long'].includes(
143
+ lookup[obj[0]].type,
144
+ )
145
+ ? val.value
146
+ : "'" + val.value + "'",
147
+ )
148
+ .join(', ')})`
149
+ : '',
150
+ ),
151
+ dimensions: updatedDimensions,
152
+ });
153
+ };
154
+
155
+ // Handle when one or more dimensions are selected from the dropdown
156
+ // Note that this is only available to metrics
157
+ const handleAddDimensions = event => {
158
+ const updatedDimensions = event.map(
159
+ selectedDimension => selectedDimension.value,
160
+ );
161
+ setSelection({
162
+ filters: selection.filters,
163
+ dimensions: updatedDimensions,
164
+ });
165
+ };
166
+
167
+ const filters = dimensions.map(grouping => {
168
+ const dimensionsInGroup = grouping[1];
169
+ const dimensionGroupOptions = dimensionsInGroup
170
+ .filter(dim => dim.is_primary_key === true)
171
+ .map(dim => {
172
+ return {
173
+ value: dim.name,
174
+ label: labelize(dim.name.split('.').slice(-1)[0]),
175
+ metadata: dim,
176
+ };
177
+ });
178
+ return (
179
+ <>
180
+ <div className="dimensionsList">
181
+ {dimensionGroupOptions.map(dimension => {
182
+ return (
183
+ <DimensionFilter
184
+ dimension={dimension}
185
+ onChange={handleAddFilters}
186
+ />
187
+ );
188
+ })}
189
+ </div>
190
+ </>
191
+ );
192
+ });
193
+
194
+ return (
195
+ <Formik initialValues={initialValues} onSubmit={handleSubmit}>
196
+ {function Render({ isSubmitting, status, setFieldValue }) {
197
+ return (
198
+ <Form>
199
+ <div className={'queryrunner'}>
200
+ <div className="queryrunner-filters left">
201
+ {node?.type === 'metric' ? (
202
+ <>
203
+ <span>
204
+ <label>Group By</label>
205
+ </span>
206
+ <Select
207
+ name="dimensions"
208
+ options={dimensionsList}
209
+ isMulti
210
+ isClearable
211
+ onChange={handleAddDimensions}
212
+ />
213
+ <br />
214
+ </>
215
+ ) : null}
216
+ <span>
217
+ <label>Add Filters</label>
218
+ </span>
219
+ {filters}
220
+ </div>
221
+ <div className={'righttop'}>
222
+ <label>Generated Query</label>
223
+ <div
224
+ style={{
225
+ height: '200px',
226
+ width: '80%',
227
+ overflow: 'scroll',
228
+ borderRadius: '0',
229
+ border: '1px solid #ccc',
230
+ }}
231
+ className="queryrunner-query"
232
+ >
233
+ <SyntaxHighlighter
234
+ language="sql"
235
+ style={foundation}
236
+ wrapLines={true}
237
+ >
238
+ {query}
239
+ </SyntaxHighlighter>
240
+ </div>
241
+ <button
242
+ type="submit"
243
+ disabled={
244
+ running ||
245
+ (queryInfo !== null &&
246
+ queryInfo?.state !== 'FINISHED' &&
247
+ queryInfo?.state !== 'CANCELED' &&
248
+ queryInfo?.state !== 'FAILED')
249
+ }
250
+ className="button-3 execute-button"
251
+ style={{ marginTop: '1rem' }}
252
+ >
253
+ {isSubmitting || running === true ? <LoadingIcon /> : '► Run'}
254
+ </button>
255
+ </div>
256
+ <div
257
+ style={{
258
+ width: window.innerWidth * 0.8,
259
+ marginTop: '1rem',
260
+ marginLeft: '0.5rem',
261
+ height: 'calc(70% - 5.5px)',
262
+ }}
263
+ className={'rightbottom'}
264
+ >
265
+ <div
266
+ className={'align-items-center row'}
267
+ style={{
268
+ borderBottom: '1px solid #dddddd',
269
+ width: '86%',
270
+ }}
271
+ >
272
+ <div
273
+ className={
274
+ 'tab-item' +
275
+ (state.selectedTab === 'results' ? ' active' : '')
276
+ }
277
+ onClick={_ => switchTab('results')}
278
+ >
279
+ Results
280
+ </div>
281
+ <div
282
+ className={
283
+ 'tab-item' +
284
+ (state.selectedTab === 'info' ? ' active' : '')
285
+ }
286
+ aria-label={'QueryInfo'}
287
+ role={'button'}
288
+ onClick={_ => switchTab('info')}
289
+ >
290
+ Info
291
+ </div>
292
+ </div>
293
+ {state.selectedTab === 'info' ? (
294
+ <div>
295
+ {queryInfo && queryInfo.id ? (
296
+ <QueryInfo {...queryInfo} isList={true} />
297
+ ) : (
298
+ <></>
299
+ )}
300
+ </div>
301
+ ) : null}
302
+ {state.selectedTab === 'results' ? (
303
+ <div>
304
+ {queryInfo !== null && queryInfo.state !== 'FINISHED' ? (
305
+ <div style={{ padding: '2rem' }}>
306
+ The query has status {queryInfo.state}! Check the INFO
307
+ tab for more details.
308
+ </div>
309
+ ) : queryInfo !== null &&
310
+ queryInfo.results !== null &&
311
+ queryInfo.results.length === 0 ? (
312
+ <div style={{ padding: '2rem' }}>
313
+ The query finished but output no results.
314
+ </div>
315
+ ) : queryInfo !== null &&
316
+ queryInfo.results !== null &&
317
+ queryInfo.results.length > 0 ? (
318
+ <div
319
+ className="table-responsive"
320
+ style={{
321
+ gridGap: '0',
322
+ width: '86%',
323
+ padding: '0',
324
+ maxHeight: '50%',
325
+ }}
326
+ >
327
+ <table
328
+ style={{ marginTop: '0 !important' }}
329
+ className="table"
330
+ >
331
+ <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
332
+ <tr>
333
+ {queryInfo.results[0]?.columns.map(columnName => (
334
+ <th key={columnName.name}>
335
+ {columnName.column}
336
+ </th>
337
+ ))}
338
+ </tr>
339
+ </thead>
340
+ <tbody>
341
+ {queryInfo.results[0]?.rows
342
+ .slice(0, 100)
343
+ .map((rowData, index) => (
344
+ <tr key={`data-row:${index}`}>
345
+ {rowData.map(rowValue => (
346
+ <td key={rowValue}>{rowValue}</td>
347
+ ))}
348
+ </tr>
349
+ ))}
350
+ </tbody>
351
+ </table>
352
+ </div>
353
+ ) : (
354
+ <div style={{ padding: '2rem' }}>
355
+ Click "Run" to execute the query.
356
+ </div>
357
+ )}
358
+ </div>
359
+ ) : null}
360
+ </div>
361
+ </div>
362
+ </Form>
363
+ );
364
+ }}
365
+ </Formik>
366
+ );
367
+ }