datajunction-ui 0.0.1-a48 → 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 +1 -1
- package/src/app/components/QueryInfo.jsx +96 -1
- package/src/app/icons/LoadingIcon.jsx +1 -1
- package/src/app/pages/NodePage/DimensionFilter.jsx +93 -0
- package/src/app/pages/NodePage/NodeValidateTab.jsx +370 -0
- package/src/app/pages/NodePage/__tests__/DimensionFilter.test.jsx +74 -0
- package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +80 -6
- package/src/app/pages/NodePage/index.jsx +6 -7
- package/src/app/services/DJService.js +58 -7
- package/src/app/services/__tests__/DJService.test.jsx +62 -0
- package/src/mocks/mockNodes.jsx +63 -0
- package/src/styles/index.css +149 -0
- package/src/app/pages/NodePage/NodeSQLTab.jsx +0 -82
package/package.json
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
+
});
|