datajunction-ui 0.0.1-a47 → 0.0.1-a49

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.1a47",
3
+ "version": "0.0.1a49",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -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,93 @@
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
+ // TODO: we're disabling this for now because it's unclear how performant the dimensions node
37
+ // data endpoints are. To re-enable, uncomment the following line:
38
+ // const data = await djClient.nodeData(dimension.metadata.node_name);
39
+ const data = { results: [] };
40
+
41
+ // TODO: when the above is enabled, this will use each dimension node's 'Label' column
42
+ // to build the display label for the dropdown, while continuing to pass the primary
43
+ // key in for filtering
44
+ /* istanbul ignore if */
45
+ if (dimensionNode && data.results && data.results.length > 0) {
46
+ const columnNames = data.results[0].columns.map(
47
+ column => column.semantic_entity,
48
+ );
49
+ const dimValues = data.results[0].rows.map(row => {
50
+ const rowData = { value: '', label: '' };
51
+ row.forEach((value, index) => {
52
+ if (columnNames[index] === primaryKey) {
53
+ rowData.value = value;
54
+ if (rowData.label === '') {
55
+ rowData.label = value;
56
+ }
57
+ } else if (columnNames[index] === label) {
58
+ rowData.label = value;
59
+ }
60
+ });
61
+ return rowData;
62
+ });
63
+ setDimensionValues(dimValues);
64
+ }
65
+ }
66
+ };
67
+ fetchData().catch(console.error);
68
+ }, [dimension.metadata.node_name, djClient]);
69
+
70
+ return (
71
+ <div key={dimension.metadata.node_name}>
72
+ {dimension.label.split('[').slice(0)[0]} (
73
+ {
74
+ <a href={`/nodes/${dimension.metadata.node_name}`}>
75
+ {dimension.metadata.node_display_name}
76
+ </a>
77
+ }
78
+ )
79
+ <CreatableSelect
80
+ name="dimensions"
81
+ options={dimensionValues}
82
+ isMulti
83
+ isClearable
84
+ onChange={event => {
85
+ onChange({
86
+ dimension: dimension.value,
87
+ values: event,
88
+ });
89
+ }}
90
+ />
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,370 @@
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 (messageData.results && messageData.results?.length > 0) {
97
+ messageData.numRows = messageData.results?.length
98
+ ? messageData.results[0].rows.length
99
+ : [];
100
+ switchTab('results');
101
+ } else {
102
+ switchTab('info');
103
+ }
104
+ setQueryInfo(messageData);
105
+ };
106
+ sse.onerror = () => sse.close();
107
+ setRunning(false);
108
+ };
109
+
110
+ // Handle form submission (runs the query)
111
+ const handleSubmit = async (values, { setSubmitting, setStatus }) => {
112
+ await runQuery(values, setStatus, setSubmitting).then(_ => {
113
+ window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
114
+ setSubmitting(false);
115
+ });
116
+ };
117
+
118
+ // Handle when filter values are updated. This is available for all nodes.
119
+ const handleAddFilters = event => {
120
+ const updatedFilters = selectedFilters;
121
+ if (event.dimension in updatedFilters) {
122
+ updatedFilters[event.dimension].operator = event.operator;
123
+ updatedFilters[event.dimension].values = event.values;
124
+ } else {
125
+ updatedFilters[event.dimension] = {
126
+ operator: event.operator,
127
+ values: event.values,
128
+ };
129
+ }
130
+ setSelectedFilters(updatedFilters);
131
+ const updatedDimensions = selection.dimensions.concat([event.dimension]);
132
+ setSelection({
133
+ filters: Object.entries(updatedFilters).map(obj =>
134
+ obj[1].values
135
+ ? `${obj[0]} IN (${obj[1].values
136
+ .map(val =>
137
+ ['int', 'bigint', 'float', 'double', 'long'].includes(
138
+ lookup[obj[0]].type,
139
+ )
140
+ ? val.value
141
+ : "'" + val.value + "'",
142
+ )
143
+ .join(', ')})`
144
+ : '',
145
+ ),
146
+ dimensions: updatedDimensions,
147
+ });
148
+ };
149
+
150
+ // Handle when one or more dimensions are selected from the dropdown
151
+ // Note that this is only available to metrics
152
+ const handleAddDimensions = event => {
153
+ const updatedDimensions = event.map(
154
+ selectedDimension => selectedDimension.value,
155
+ );
156
+ setSelection({
157
+ filters: selection.filters,
158
+ dimensions: updatedDimensions,
159
+ });
160
+ };
161
+
162
+ const filters = dimensions.map(grouping => {
163
+ const dimensionsInGroup = grouping[1];
164
+ const dimensionGroupOptions = dimensionsInGroup
165
+ .filter(dim => dim.is_primary_key === true)
166
+ .map(dim => {
167
+ return {
168
+ value: dim.name,
169
+ label: labelize(dim.name.split('.').slice(-1)[0]),
170
+ metadata: dim,
171
+ };
172
+ });
173
+ return (
174
+ <>
175
+ <div className="dimensionsList">
176
+ {dimensionGroupOptions.map(dimension => {
177
+ return (
178
+ <DimensionFilter
179
+ dimension={dimension}
180
+ onChange={handleAddFilters}
181
+ />
182
+ );
183
+ })}
184
+ </div>
185
+ </>
186
+ );
187
+ });
188
+
189
+ return (
190
+ <Formik initialValues={initialValues} onSubmit={handleSubmit}>
191
+ {function Render({ isSubmitting, status, setFieldValue }) {
192
+ return (
193
+ <Form>
194
+ <div className={'queryrunner'}>
195
+ <div className="queryrunner-filters left">
196
+ {node?.type === 'metric' ? (
197
+ <>
198
+ <span>
199
+ <label>Group By</label>
200
+ </span>
201
+ <Select
202
+ name="dimensions"
203
+ options={dimensionsList}
204
+ isMulti
205
+ isClearable
206
+ onChange={handleAddDimensions}
207
+ />
208
+ <br />
209
+ </>
210
+ ) : null}
211
+ <span>
212
+ <label>Add Filters</label>
213
+ </span>
214
+ {filters}
215
+ </div>
216
+ <div className={'righttop'}>
217
+ <label>Generated Query</label>
218
+ <div
219
+ style={{
220
+ height: '200px',
221
+ width: '80%',
222
+ overflow: 'scroll',
223
+ borderRadius: '0',
224
+ border: '1px solid #ccc',
225
+ }}
226
+ className="queryrunner-query"
227
+ >
228
+ <SyntaxHighlighter
229
+ language="sql"
230
+ style={foundation}
231
+ wrapLines={true}
232
+ >
233
+ {query}
234
+ </SyntaxHighlighter>
235
+ </div>
236
+ <button
237
+ type="submit"
238
+ disabled={
239
+ running ||
240
+ (queryInfo !== null &&
241
+ queryInfo?.state !== 'FINISHED' &&
242
+ queryInfo?.state !== 'CANCELED' &&
243
+ queryInfo?.state !== 'FAILED')
244
+ }
245
+ className="button-3 execute-button"
246
+ style={{ marginTop: '1rem' }}
247
+ >
248
+ {running ||
249
+ (queryInfo !== null &&
250
+ queryInfo?.state !== 'FINISHED' &&
251
+ queryInfo?.state !== 'CANCELED' &&
252
+ queryInfo?.state !== 'FAILED') ? (
253
+ <LoadingIcon />
254
+ ) : (
255
+ '► Run'
256
+ )}
257
+ </button>
258
+ </div>
259
+ <div
260
+ style={{
261
+ width: window.innerWidth * 0.8,
262
+ marginTop: '1rem',
263
+ marginLeft: '0.5rem',
264
+ height: 'calc(70% - 5.5px)',
265
+ }}
266
+ className={'rightbottom'}
267
+ >
268
+ <div
269
+ className={'align-items-center row'}
270
+ style={{
271
+ borderBottom: '1px solid #dddddd',
272
+ width: '86%',
273
+ }}
274
+ >
275
+ <div
276
+ className={
277
+ 'tab-item' +
278
+ (state.selectedTab === 'results' ? ' active' : '')
279
+ }
280
+ onClick={_ => switchTab('results')}
281
+ >
282
+ Results
283
+ </div>
284
+ <div
285
+ className={
286
+ 'tab-item' +
287
+ (state.selectedTab === 'info' ? ' active' : '')
288
+ }
289
+ aria-label={'QueryInfo'}
290
+ role={'button'}
291
+ onClick={_ => switchTab('info')}
292
+ >
293
+ Info
294
+ </div>
295
+ </div>
296
+ {state.selectedTab === 'info' ? (
297
+ <div>
298
+ {queryInfo && queryInfo.id ? (
299
+ <QueryInfo {...queryInfo} isList={true} />
300
+ ) : (
301
+ <></>
302
+ )}
303
+ </div>
304
+ ) : null}
305
+ {state.selectedTab === 'results' ? (
306
+ <div>
307
+ {queryInfo !== null && queryInfo.state !== 'FINISHED' ? (
308
+ <div style={{ padding: '2rem' }}>
309
+ The query has status {queryInfo.state}! Check the INFO
310
+ tab for more details.
311
+ </div>
312
+ ) : queryInfo !== null &&
313
+ queryInfo.results !== null &&
314
+ queryInfo.results.length === 0 ? (
315
+ <div style={{ padding: '2rem' }}>
316
+ The query finished but output no results.
317
+ </div>
318
+ ) : queryInfo !== null &&
319
+ queryInfo.results !== null &&
320
+ queryInfo.results.length > 0 ? (
321
+ <div
322
+ className="table-responsive"
323
+ style={{
324
+ gridGap: '0',
325
+ width: '86%',
326
+ padding: '0',
327
+ maxHeight: '50%',
328
+ }}
329
+ >
330
+ <table
331
+ style={{ marginTop: '0 !important' }}
332
+ className="table"
333
+ >
334
+ <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
335
+ <tr>
336
+ {queryInfo.results[0]?.columns.map(columnName => (
337
+ <th key={columnName.name}>
338
+ {columnName.column}
339
+ </th>
340
+ ))}
341
+ </tr>
342
+ </thead>
343
+ <tbody>
344
+ {queryInfo.results[0]?.rows
345
+ .slice(0, 100)
346
+ .map((rowData, index) => (
347
+ <tr key={`data-row:${index}`}>
348
+ {rowData.map(rowValue => (
349
+ <td key={rowValue}>{rowValue}</td>
350
+ ))}
351
+ </tr>
352
+ ))}
353
+ </tbody>
354
+ </table>
355
+ </div>
356
+ ) : (
357
+ <div style={{ padding: '2rem' }}>
358
+ Click "Run" to execute the query.
359
+ </div>
360
+ )}
361
+ </div>
362
+ ) : null}
363
+ </div>
364
+ </div>
365
+ </Form>
366
+ );
367
+ }}
368
+ </Formik>
369
+ );
370
+ }
@@ -0,0 +1,74 @@
1
+ import React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import DJClientContext from '../../../providers/djclient';
5
+ import DimensionFilter from '../DimensionFilter';
6
+
7
+ // Mock DJClientContext
8
+ const mockDJClient = {
9
+ DataJunctionAPI: {
10
+ node: jest.fn(),
11
+ nodeData: jest.fn(),
12
+ },
13
+ };
14
+
15
+ const mockDimension = {
16
+ label: 'Dimension Label [Test]',
17
+ value: 'dimension_value',
18
+ metadata: {
19
+ node_name: 'test_node',
20
+ node_display_name: 'Test Node',
21
+ },
22
+ };
23
+
24
+ const getByTextStartsWith = (container, text) => {
25
+ return Array.from(container.querySelectorAll('*')).find(element => {
26
+ return element.textContent.trim().startsWith(text);
27
+ });
28
+ };
29
+
30
+ describe('DimensionFilter', () => {
31
+ it('fetches dimension data and renders correctly', async () => {
32
+ // Mock node response
33
+ const mockNodeResponse = {
34
+ type: 'dimension',
35
+ name: 'test_node',
36
+ columns: [
37
+ {
38
+ name: 'id',
39
+ attributes: [{ attribute_type: { name: 'primary_key' } }],
40
+ },
41
+ { name: 'name', attributes: [{ attribute_type: { name: 'label' } }] },
42
+ ],
43
+ };
44
+ mockDJClient.DataJunctionAPI.node.mockResolvedValue(mockNodeResponse);
45
+
46
+ // Mock node data response
47
+ const mockNodeDataResponse = {
48
+ results: [
49
+ {
50
+ columns: [{ semantic_entity: 'id' }, { semantic_entity: 'name' }],
51
+ rows: [
52
+ [1, 'Value 1'],
53
+ [2, 'Value 2'],
54
+ ],
55
+ },
56
+ ],
57
+ };
58
+ mockDJClient.DataJunctionAPI.nodeData.mockResolvedValue(
59
+ mockNodeDataResponse,
60
+ );
61
+
62
+ const { container } = render(
63
+ <DJClientContext.Provider value={mockDJClient}>
64
+ <DimensionFilter dimension={mockDimension} onChange={jest.fn()} />
65
+ </DJClientContext.Provider>,
66
+ );
67
+
68
+ // Check if the dimension label and node display name are rendered
69
+ expect(
70
+ getByTextStartsWith(container, 'Dimension Label'),
71
+ ).toBeInTheDocument();
72
+ expect(screen.getByText('Test Node')).toBeInTheDocument();
73
+ });
74
+ });