datajunction-ui 0.0.95 → 0.0.97
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/Search.jsx +26 -25
- package/src/app/components/Tab.jsx +6 -1
- package/src/app/components/__tests__/Search.test.jsx +15 -23
- package/src/app/components/search.css +43 -5
- package/src/app/icons/ChartIcon.jsx +28 -0
- package/src/app/pages/NodePage/index.jsx +6 -2
- package/src/app/pages/QueryPlannerPage/ResultsView.jsx +182 -36
- package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +10 -6
- package/src/app/pages/QueryPlannerPage/styles.css +9 -0
- package/src/app/pages/Root/__tests__/index.test.jsx +3 -2
- package/src/app/pages/Root/index.tsx +1 -1
- package/src/app/services/DJService.js +22 -0
- package/src/app/services/__tests__/DJService.test.jsx +11 -2
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ export default function Search() {
|
|
|
10
10
|
const [searchResults, setSearchResults] = useState([]);
|
|
11
11
|
const [isLoading, setIsLoading] = useState(false);
|
|
12
12
|
const hasLoadedRef = useRef(false);
|
|
13
|
+
const inputRef = useRef(null);
|
|
13
14
|
|
|
14
15
|
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
15
16
|
|
|
@@ -64,39 +65,39 @@ export default function Search() {
|
|
|
64
65
|
|
|
65
66
|
return (
|
|
66
67
|
<div>
|
|
67
|
-
<
|
|
68
|
-
className="search-box"
|
|
69
|
-
onSubmit={e => {
|
|
70
|
-
e.preventDefault();
|
|
71
|
-
}}
|
|
72
|
-
>
|
|
68
|
+
<div className="nav-search-box" onClick={() => inputRef.current?.focus()}>
|
|
73
69
|
<input
|
|
70
|
+
ref={inputRef}
|
|
74
71
|
type="text"
|
|
75
|
-
placeholder={isLoading ? 'Loading...' : 'Search'}
|
|
72
|
+
placeholder={isLoading ? 'Loading...' : 'Search nodes...'}
|
|
76
73
|
name="search"
|
|
77
74
|
value={searchValue}
|
|
78
75
|
onChange={handleChange}
|
|
79
76
|
onFocus={loadSearchData}
|
|
80
77
|
/>
|
|
81
|
-
</form>
|
|
82
|
-
<div className="search-results">
|
|
83
|
-
{searchResults.slice(0, 20).map(item => {
|
|
84
|
-
const itemUrl =
|
|
85
|
-
item.type !== 'tag' ? `/nodes/${item.name}` : `/tags/${item.name}`;
|
|
86
|
-
return (
|
|
87
|
-
<a key={item.name} href={itemUrl}>
|
|
88
|
-
<div className="search-result-item">
|
|
89
|
-
<span className={`node_type__${item.type} badge node_type`}>
|
|
90
|
-
{item.type}
|
|
91
|
-
</span>
|
|
92
|
-
{item.display_name} (<b>{item.name}</b>){' '}
|
|
93
|
-
{item.description ? '- ' : ' '}
|
|
94
|
-
{truncate(item.description || '')}
|
|
95
|
-
</div>
|
|
96
|
-
</a>
|
|
97
|
-
);
|
|
98
|
-
})}
|
|
99
78
|
</div>
|
|
79
|
+
{searchResults.length > 0 && (
|
|
80
|
+
<div className="search-results">
|
|
81
|
+
{searchResults.slice(0, 20).map(item => {
|
|
82
|
+
const itemUrl =
|
|
83
|
+
item.type !== 'tag'
|
|
84
|
+
? `/nodes/${item.name}`
|
|
85
|
+
: `/tags/${item.name}`;
|
|
86
|
+
return (
|
|
87
|
+
<a key={item.name} href={itemUrl}>
|
|
88
|
+
<div className="search-result-item">
|
|
89
|
+
<span className={`node_type__${item.type} badge node_type`}>
|
|
90
|
+
{item.type}
|
|
91
|
+
</span>
|
|
92
|
+
{item.display_name} (<b>{item.name}</b>){' '}
|
|
93
|
+
{item.description ? '- ' : ' '}
|
|
94
|
+
{truncate(item.description || '')}
|
|
95
|
+
</div>
|
|
96
|
+
</a>
|
|
97
|
+
);
|
|
98
|
+
})}
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
100
101
|
</div>
|
|
101
102
|
);
|
|
102
103
|
}
|
|
@@ -13,7 +13,12 @@ export default class Tab extends Component {
|
|
|
13
13
|
aria-label={this.props.name}
|
|
14
14
|
aria-hidden="false"
|
|
15
15
|
>
|
|
16
|
-
|
|
16
|
+
<span
|
|
17
|
+
style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}
|
|
18
|
+
>
|
|
19
|
+
{this.props.icon}
|
|
20
|
+
{this.props.name}
|
|
21
|
+
</span>
|
|
17
22
|
</button>
|
|
18
23
|
);
|
|
19
24
|
}
|
|
@@ -56,7 +56,7 @@ describe('<Search />', () => {
|
|
|
56
56
|
</DJClientContext.Provider>,
|
|
57
57
|
);
|
|
58
58
|
|
|
59
|
-
expect(getByPlaceholderText('Search')).toBeInTheDocument();
|
|
59
|
+
expect(getByPlaceholderText('Search nodes...')).toBeInTheDocument();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
it('fetches and initializes search data on focus (lazy loading)', async () => {
|
|
@@ -73,7 +73,7 @@ describe('<Search />', () => {
|
|
|
73
73
|
expect(mockDjClient.DataJunctionAPI.nodeDetails).not.toHaveBeenCalled();
|
|
74
74
|
|
|
75
75
|
// Focus on search input to trigger lazy loading
|
|
76
|
-
const searchInput = getByPlaceholderText('Search');
|
|
76
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
77
77
|
fireEvent.focus(searchInput);
|
|
78
78
|
|
|
79
79
|
await waitFor(() => {
|
|
@@ -92,7 +92,7 @@ describe('<Search />', () => {
|
|
|
92
92
|
</DJClientContext.Provider>,
|
|
93
93
|
);
|
|
94
94
|
|
|
95
|
-
const searchInput = getByPlaceholderText('Search');
|
|
95
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
96
96
|
// Focus to trigger lazy loading
|
|
97
97
|
fireEvent.focus(searchInput);
|
|
98
98
|
|
|
@@ -117,7 +117,7 @@ describe('<Search />', () => {
|
|
|
117
117
|
</DJClientContext.Provider>,
|
|
118
118
|
);
|
|
119
119
|
|
|
120
|
-
const searchInput = getByPlaceholderText('Search');
|
|
120
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
121
121
|
// Focus to trigger lazy loading
|
|
122
122
|
fireEvent.focus(searchInput);
|
|
123
123
|
|
|
@@ -143,7 +143,7 @@ describe('<Search />', () => {
|
|
|
143
143
|
</DJClientContext.Provider>,
|
|
144
144
|
);
|
|
145
145
|
|
|
146
|
-
const searchInput = getByPlaceholderText('Search');
|
|
146
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
147
147
|
// Focus to trigger lazy loading
|
|
148
148
|
fireEvent.focus(searchInput);
|
|
149
149
|
|
|
@@ -169,7 +169,7 @@ describe('<Search />', () => {
|
|
|
169
169
|
</DJClientContext.Provider>,
|
|
170
170
|
);
|
|
171
171
|
|
|
172
|
-
const searchInput = getByPlaceholderText('Search');
|
|
172
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
173
173
|
// Focus to trigger lazy loading
|
|
174
174
|
fireEvent.focus(searchInput);
|
|
175
175
|
|
|
@@ -194,7 +194,7 @@ describe('<Search />', () => {
|
|
|
194
194
|
</DJClientContext.Provider>,
|
|
195
195
|
);
|
|
196
196
|
|
|
197
|
-
const searchInput = getByPlaceholderText('Search');
|
|
197
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
198
198
|
// Focus to trigger lazy loading
|
|
199
199
|
fireEvent.focus(searchInput);
|
|
200
200
|
|
|
@@ -226,7 +226,7 @@ describe('<Search />', () => {
|
|
|
226
226
|
</DJClientContext.Provider>,
|
|
227
227
|
);
|
|
228
228
|
|
|
229
|
-
const searchInput = getByPlaceholderText('Search');
|
|
229
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
230
230
|
// Focus to trigger lazy loading
|
|
231
231
|
fireEvent.focus(searchInput);
|
|
232
232
|
|
|
@@ -256,7 +256,7 @@ describe('<Search />', () => {
|
|
|
256
256
|
);
|
|
257
257
|
|
|
258
258
|
// Focus to trigger lazy loading
|
|
259
|
-
const searchInput = getByPlaceholderText('Search');
|
|
259
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
260
260
|
fireEvent.focus(searchInput);
|
|
261
261
|
|
|
262
262
|
await waitFor(() => {
|
|
@@ -269,7 +269,7 @@ describe('<Search />', () => {
|
|
|
269
269
|
consoleErrorSpy.mockRestore();
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it('
|
|
272
|
+
it('renders search input inside nav-search-box container', async () => {
|
|
273
273
|
mockDjClient.DataJunctionAPI.nodeDetails.mockResolvedValue([]);
|
|
274
274
|
mockDjClient.DataJunctionAPI.listTags.mockResolvedValue([]);
|
|
275
275
|
|
|
@@ -279,17 +279,9 @@ describe('<Search />', () => {
|
|
|
279
279
|
</DJClientContext.Provider>,
|
|
280
280
|
);
|
|
281
281
|
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
bubbles: true,
|
|
286
|
-
cancelable: true,
|
|
287
|
-
});
|
|
288
|
-
const preventDefaultSpy = jest.spyOn(submitEvent, 'preventDefault');
|
|
289
|
-
|
|
290
|
-
form.dispatchEvent(submitEvent);
|
|
291
|
-
|
|
292
|
-
expect(preventDefaultSpy).toHaveBeenCalled();
|
|
282
|
+
const searchBox = container.querySelector('.nav-search-box');
|
|
283
|
+
expect(searchBox).toBeInTheDocument();
|
|
284
|
+
expect(searchBox.querySelector('input')).toBeInTheDocument();
|
|
293
285
|
});
|
|
294
286
|
|
|
295
287
|
it('handles empty tags array', async () => {
|
|
@@ -302,7 +294,7 @@ describe('<Search />', () => {
|
|
|
302
294
|
</DJClientContext.Provider>,
|
|
303
295
|
);
|
|
304
296
|
|
|
305
|
-
const searchInput = getByPlaceholderText('Search');
|
|
297
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
306
298
|
// Focus to trigger lazy loading
|
|
307
299
|
fireEvent.focus(searchInput);
|
|
308
300
|
|
|
@@ -324,7 +316,7 @@ describe('<Search />', () => {
|
|
|
324
316
|
</DJClientContext.Provider>,
|
|
325
317
|
);
|
|
326
318
|
|
|
327
|
-
const searchInput = getByPlaceholderText('Search');
|
|
319
|
+
const searchInput = getByPlaceholderText('Search nodes...');
|
|
328
320
|
// Focus to trigger lazy loading
|
|
329
321
|
fireEvent.focus(searchInput);
|
|
330
322
|
|
|
@@ -1,17 +1,55 @@
|
|
|
1
|
-
.search-box {
|
|
1
|
+
.nav-search-box {
|
|
2
2
|
display: flex;
|
|
3
|
-
|
|
3
|
+
align-items: center;
|
|
4
|
+
gap: 4px;
|
|
5
|
+
padding: 6px 10px;
|
|
6
|
+
background: var(--planner-bg, #f8fafc);
|
|
7
|
+
border: 1px solid var(--planner-border, #e2e8f0);
|
|
8
|
+
border-radius: 6px;
|
|
9
|
+
height: 32px;
|
|
10
|
+
cursor: text;
|
|
11
|
+
transition: border-color 0.15s;
|
|
12
|
+
position: relative;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.nav-search-box:focus-within {
|
|
16
|
+
border-color: var(--accent-primary, #3b82f6);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.nav-search-box input {
|
|
20
|
+
flex: 1;
|
|
21
|
+
min-width: 160px;
|
|
22
|
+
border: none;
|
|
23
|
+
background: transparent;
|
|
24
|
+
outline: none;
|
|
25
|
+
box-shadow: none;
|
|
26
|
+
-webkit-appearance: none;
|
|
27
|
+
appearance: none;
|
|
28
|
+
font-size: 12px;
|
|
29
|
+
color: var(--planner-text, #1e293b);
|
|
30
|
+
padding: 2px 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.nav-search-box input::placeholder {
|
|
34
|
+
color: var(--planner-text-dim, #94a3b8);
|
|
4
35
|
}
|
|
5
36
|
|
|
6
37
|
.search-results {
|
|
7
38
|
position: absolute;
|
|
8
39
|
z-index: 1000;
|
|
9
40
|
width: 75%;
|
|
10
|
-
background-color:
|
|
11
|
-
border
|
|
41
|
+
background-color: var(--planner-surface, #ffffff);
|
|
42
|
+
border: 1px solid var(--planner-border, #e2e8f0);
|
|
43
|
+
border-radius: 6px;
|
|
44
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
12
45
|
}
|
|
13
46
|
|
|
14
47
|
.search-result-item {
|
|
15
|
-
text-decoration: wavy;
|
|
16
48
|
padding: 0.5rem;
|
|
49
|
+
color: var(--planner-text, #1e293b);
|
|
50
|
+
font-size: 12px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.search-result-item:hover {
|
|
54
|
+
background-color: var(--planner-surface-hover, #f1f5f9);
|
|
17
55
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const ChartIcon = ({ size = 16, ...props }) => (
|
|
2
|
+
<svg
|
|
3
|
+
width={size}
|
|
4
|
+
height={size}
|
|
5
|
+
viewBox="0 0 24 24"
|
|
6
|
+
fill="currentColor"
|
|
7
|
+
stroke="none"
|
|
8
|
+
{...props}
|
|
9
|
+
>
|
|
10
|
+
{/* Bars */}
|
|
11
|
+
<rect x="4" y="11" width="5" height="8" rx="1" />
|
|
12
|
+
<rect x="10" y="5" width="5" height="14" rx="1" />
|
|
13
|
+
<rect x="16" y="8" width="5" height="11" rx="1" />
|
|
14
|
+
{/* X axis */}
|
|
15
|
+
<line
|
|
16
|
+
x1="2"
|
|
17
|
+
y1="21"
|
|
18
|
+
x2="23"
|
|
19
|
+
y2="21"
|
|
20
|
+
stroke="currentColor"
|
|
21
|
+
strokeWidth="1.5"
|
|
22
|
+
strokeLinecap="round"
|
|
23
|
+
opacity="0.35"
|
|
24
|
+
/>
|
|
25
|
+
</svg>
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
export default ChartIcon;
|
|
@@ -17,6 +17,7 @@ import WatchButton from './WatchNodeButton';
|
|
|
17
17
|
import NodesWithDimension from './NodesWithDimension';
|
|
18
18
|
import NodeColumnLineage from './NodeLineageTab';
|
|
19
19
|
import EditIcon from '../../icons/EditIcon';
|
|
20
|
+
import ChartIcon from '../../icons/ChartIcon';
|
|
20
21
|
import AlertIcon from '../../icons/AlertIcon';
|
|
21
22
|
import LoadingIcon from '../../icons/LoadingIcon';
|
|
22
23
|
import NodeDependenciesTab from './NodeDependenciesTab';
|
|
@@ -39,7 +40,8 @@ export function NodePage() {
|
|
|
39
40
|
const onClickTab = id => () => {
|
|
40
41
|
// Preview tab redirects to Query Planner instead of showing content
|
|
41
42
|
if (id === 'preview') {
|
|
42
|
-
|
|
43
|
+
const param = node?.type === 'cube' ? 'cube' : 'metrics';
|
|
44
|
+
navigate(`/planner?${param}=${encodeURIComponent(name)}`);
|
|
43
45
|
return;
|
|
44
46
|
}
|
|
45
47
|
navigate(`/nodes/${name}/${id}`);
|
|
@@ -52,6 +54,7 @@ export function NodePage() {
|
|
|
52
54
|
key={tab.id}
|
|
53
55
|
id={tab.id}
|
|
54
56
|
name={tab.name}
|
|
57
|
+
icon={tab.icon}
|
|
55
58
|
onClick={onClickTab(tab.id)}
|
|
56
59
|
selectedTab={state.selectedTab}
|
|
57
60
|
/>
|
|
@@ -121,7 +124,8 @@ export function NodePage() {
|
|
|
121
124
|
{
|
|
122
125
|
id: 'preview',
|
|
123
126
|
name: 'Preview →',
|
|
124
|
-
|
|
127
|
+
icon: <ChartIcon size={18} />,
|
|
128
|
+
display: node?.type === 'metric' || node?.type === 'cube',
|
|
125
129
|
},
|
|
126
130
|
];
|
|
127
131
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect, memo } from 'react';
|
|
2
2
|
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
3
3
|
import { foundation } from 'react-syntax-highlighter/src/styles/hljs';
|
|
4
4
|
import sql from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
|
|
@@ -17,13 +17,13 @@ import {
|
|
|
17
17
|
SyntaxHighlighter.registerLanguage('sql', sql);
|
|
18
18
|
|
|
19
19
|
const SERIES_COLORS = [
|
|
20
|
-
'#
|
|
21
|
-
'#
|
|
22
|
-
'#
|
|
23
|
-
'#
|
|
24
|
-
'#
|
|
25
|
-
'#
|
|
26
|
-
'#
|
|
20
|
+
'#60a5fa',
|
|
21
|
+
'#34d399',
|
|
22
|
+
'#fbbf24',
|
|
23
|
+
'#f87171',
|
|
24
|
+
'#a78bfa',
|
|
25
|
+
'#22d3ee',
|
|
26
|
+
'#fb923c',
|
|
27
27
|
];
|
|
28
28
|
|
|
29
29
|
// Threshold for switching from multi-series to small multiples
|
|
@@ -73,19 +73,41 @@ function detectChartConfig(columns, rows) {
|
|
|
73
73
|
const timeCols = tagged.filter(c => isTimeColumn(c));
|
|
74
74
|
const numericCols = tagged.filter(c => isNumericColumn(c));
|
|
75
75
|
const nonNumericCols = tagged.filter(c => !isNumericColumn(c));
|
|
76
|
+
const nonTimeCatCols = nonNumericCols.filter(c => !isTimeColumn(c));
|
|
76
77
|
|
|
77
|
-
// Time dimension present → line chart
|
|
78
|
+
// Time dimension present → line chart
|
|
78
79
|
if (timeCols.length > 0) {
|
|
79
80
|
const xCol = timeCols[0];
|
|
80
81
|
const metricCols = numericCols.filter(c => c.idx !== xCol.idx);
|
|
81
|
-
if (metricCols.length > 0)
|
|
82
|
+
if (metricCols.length > 0) {
|
|
83
|
+
// Exactly one categorical dim + one metric → pivot as series
|
|
84
|
+
if (nonTimeCatCols.length === 1) {
|
|
85
|
+
return {
|
|
86
|
+
type: 'line',
|
|
87
|
+
xCol,
|
|
88
|
+
groupByCol: nonTimeCatCols[0],
|
|
89
|
+
metricCols,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return { type: 'line', xCol, metricCols };
|
|
93
|
+
}
|
|
82
94
|
}
|
|
83
95
|
|
|
84
|
-
//
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
96
|
+
// Categorical dimension(s) → bar chart
|
|
97
|
+
if (nonTimeCatCols.length > 0 && numericCols.length > 0) {
|
|
98
|
+
if (nonTimeCatCols.length === 1) {
|
|
99
|
+
return { type: 'bar', xCol: nonTimeCatCols[0], metricCols: numericCols };
|
|
100
|
+
}
|
|
101
|
+
if (nonTimeCatCols.length === 2) {
|
|
102
|
+
return {
|
|
103
|
+
type: 'bar',
|
|
104
|
+
xCol: nonTimeCatCols[0],
|
|
105
|
+
groupByCol: nonTimeCatCols[1],
|
|
106
|
+
metricCols: numericCols,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// 3+ cats → fall back to first cat as x-axis
|
|
110
|
+
return { type: 'bar', xCol: nonTimeCatCols[0], metricCols: numericCols };
|
|
89
111
|
}
|
|
90
112
|
|
|
91
113
|
// Multiple numeric columns, no string/time dim → treat first as x-axis (line)
|
|
@@ -103,6 +125,59 @@ function detectChartConfig(columns, rows) {
|
|
|
103
125
|
return null;
|
|
104
126
|
}
|
|
105
127
|
|
|
128
|
+
const MAX_GROUP_VALUES = 7;
|
|
129
|
+
|
|
130
|
+
function buildPivotedData(rows, columns, xCol, groupByCol, metricCols) {
|
|
131
|
+
const xIdx = xCol.idx;
|
|
132
|
+
const gIdx = groupByCol.idx;
|
|
133
|
+
const metricIdxs = metricCols.map(c => c.idx);
|
|
134
|
+
|
|
135
|
+
// Pass 1: group totals only (cheap — just numbers)
|
|
136
|
+
const groupTotals = {};
|
|
137
|
+
for (const row of rows) {
|
|
138
|
+
const gVal = String(row[gIdx] ?? '(null)');
|
|
139
|
+
groupTotals[gVal] =
|
|
140
|
+
(groupTotals[gVal] || 0) + (Number(row[metricIdxs[0]]) || 0);
|
|
141
|
+
}
|
|
142
|
+
const groupValues = Object.entries(groupTotals)
|
|
143
|
+
.sort((a, b) => b[1] - a[1])
|
|
144
|
+
.slice(0, MAX_GROUP_VALUES)
|
|
145
|
+
.map(([k]) => k);
|
|
146
|
+
const groupSet = new Set(groupValues);
|
|
147
|
+
|
|
148
|
+
// Pass 2: build ALL metric pivot maps in one sweep
|
|
149
|
+
const pivotMaps = metricCols.map(() => ({}));
|
|
150
|
+
for (const row of rows) {
|
|
151
|
+
const gVal = String(row[gIdx] ?? '(null)');
|
|
152
|
+
if (!groupSet.has(gVal)) continue;
|
|
153
|
+
const xVal = row[xIdx];
|
|
154
|
+
const mapKey = String(xVal ?? '(null)');
|
|
155
|
+
for (let m = 0; m < metricCols.length; m++) {
|
|
156
|
+
const pm = pivotMaps[m];
|
|
157
|
+
if (!pm[mapKey]) pm[mapKey] = { [xCol.name]: xVal };
|
|
158
|
+
pm[mapKey][gVal] = row[metricIdxs[m]];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const sortFn = (a, b) => {
|
|
163
|
+
const av = a[xCol.name];
|
|
164
|
+
const bv = b[xCol.name];
|
|
165
|
+
if (av === null && bv === null) return 0;
|
|
166
|
+
if (av === null) return 1;
|
|
167
|
+
if (bv === null) return -1;
|
|
168
|
+
if (typeof av === 'number' && typeof bv === 'number') return av - bv;
|
|
169
|
+
return String(av).localeCompare(String(bv));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const pivotedByMetric = metricCols.map((metricCol, m) => {
|
|
173
|
+
const pivoted = Object.values(pivotMaps[m]);
|
|
174
|
+
pivoted.sort(sortFn);
|
|
175
|
+
return { col: metricCol, data: pivoted };
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return { pivotedByMetric, groupValues };
|
|
179
|
+
}
|
|
180
|
+
|
|
106
181
|
function buildChartData(columns, rows, xCol) {
|
|
107
182
|
const data = rows.map(row => {
|
|
108
183
|
const obj = {};
|
|
@@ -160,15 +235,20 @@ const CHART_MARGIN = { top: 8, right: 24, left: 8, bottom: 40 };
|
|
|
160
235
|
const AXIS_TICK = { fontSize: 11, fill: '#64748b' };
|
|
161
236
|
const TOOLTIP_STYLE = { fontSize: 12, border: '1px solid #e2e8f0' };
|
|
162
237
|
|
|
163
|
-
function Chart({
|
|
238
|
+
const Chart = memo(function Chart({
|
|
164
239
|
type,
|
|
165
240
|
xCol,
|
|
166
241
|
metricCols,
|
|
242
|
+
seriesKeys,
|
|
167
243
|
chartData,
|
|
168
244
|
seriesColors = SERIES_COLORS,
|
|
169
245
|
}) {
|
|
170
246
|
const showDots = chartData.length <= 60;
|
|
171
|
-
const
|
|
247
|
+
const keys = seriesKeys || metricCols.map(c => c.name);
|
|
248
|
+
const xInterval =
|
|
249
|
+
type === 'line'
|
|
250
|
+
? 'preserveStartEnd'
|
|
251
|
+
: Math.max(0, Math.ceil(chartData.length / 20) - 1);
|
|
172
252
|
if (type === 'line') {
|
|
173
253
|
return (
|
|
174
254
|
<ResponsiveContainer width="100%" height="100%">
|
|
@@ -183,14 +263,15 @@ function Chart({
|
|
|
183
263
|
/>
|
|
184
264
|
<YAxis tickFormatter={formatYAxis} tick={AXIS_TICK} width={60} />
|
|
185
265
|
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
|
186
|
-
{
|
|
266
|
+
{keys.map((key, i) => (
|
|
187
267
|
<Line
|
|
188
|
-
key={
|
|
268
|
+
key={key}
|
|
189
269
|
type="monotone"
|
|
190
|
-
dataKey={
|
|
270
|
+
dataKey={key}
|
|
191
271
|
stroke={seriesColors[i % seriesColors.length]}
|
|
192
272
|
dot={showDots}
|
|
193
273
|
strokeWidth={2}
|
|
274
|
+
isAnimationActive={false}
|
|
194
275
|
/>
|
|
195
276
|
))}
|
|
196
277
|
</LineChart>
|
|
@@ -210,27 +291,74 @@ function Chart({
|
|
|
210
291
|
/>
|
|
211
292
|
<YAxis tickFormatter={formatYAxis} tick={AXIS_TICK} width={60} />
|
|
212
293
|
<Tooltip contentStyle={TOOLTIP_STYLE} />
|
|
213
|
-
{
|
|
294
|
+
{keys.map((key, i) => (
|
|
214
295
|
<Bar
|
|
215
|
-
key={
|
|
216
|
-
dataKey={
|
|
296
|
+
key={key}
|
|
297
|
+
dataKey={key}
|
|
217
298
|
fill={seriesColors[i % seriesColors.length]}
|
|
299
|
+
isAnimationActive={false}
|
|
218
300
|
/>
|
|
219
301
|
))}
|
|
220
302
|
</BarChart>
|
|
221
303
|
</ResponsiveContainer>
|
|
222
304
|
);
|
|
223
|
-
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const ChartView = memo(function ChartView({
|
|
308
|
+
chartConfig,
|
|
309
|
+
chartData,
|
|
310
|
+
pivotedByMetric,
|
|
311
|
+
groupValues,
|
|
312
|
+
rows,
|
|
313
|
+
columns,
|
|
314
|
+
}) {
|
|
315
|
+
if (!chartConfig) {
|
|
316
|
+
return <div className="chart-no-data">No chartable data detected</div>;
|
|
317
|
+
}
|
|
224
318
|
|
|
225
|
-
function ChartView({ chartConfig, chartData, rows, columns }) {
|
|
226
319
|
if (chartConfig.type === 'kpi') {
|
|
227
320
|
return <KpiCards rows={rows} metricCols={chartConfig.metricCols} />;
|
|
228
321
|
}
|
|
229
322
|
|
|
230
323
|
const { type, xCol, metricCols } = chartConfig;
|
|
231
|
-
const useSmallMultiples = metricCols.length > SMALL_MULTIPLES_THRESHOLD;
|
|
232
324
|
|
|
233
|
-
|
|
325
|
+
// Pivoted multi-metric: small multiples, one per metric, each with groupBy series
|
|
326
|
+
if (pivotedByMetric && pivotedByMetric.length > 1) {
|
|
327
|
+
return (
|
|
328
|
+
<div className="small-multiples">
|
|
329
|
+
{pivotedByMetric.map(({ col, data }) => (
|
|
330
|
+
<div key={col.idx} className="small-multiple">
|
|
331
|
+
<div className="small-multiple-label">{col.name}</div>
|
|
332
|
+
<div className="small-multiple-chart">
|
|
333
|
+
<Chart
|
|
334
|
+
type={type}
|
|
335
|
+
xCol={xCol}
|
|
336
|
+
metricCols={[col]}
|
|
337
|
+
seriesKeys={groupValues}
|
|
338
|
+
chartData={data}
|
|
339
|
+
/>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
))}
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Pivoted single-metric: one chart with groupBy as series
|
|
348
|
+
if (groupValues) {
|
|
349
|
+
return (
|
|
350
|
+
<Chart
|
|
351
|
+
type={type}
|
|
352
|
+
xCol={xCol}
|
|
353
|
+
metricCols={metricCols}
|
|
354
|
+
seriesKeys={groupValues}
|
|
355
|
+
chartData={chartData}
|
|
356
|
+
/>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// No groupBy: standard small multiples or single chart
|
|
361
|
+
if (metricCols.length > SMALL_MULTIPLES_THRESHOLD) {
|
|
234
362
|
return (
|
|
235
363
|
<div className="small-multiples">
|
|
236
364
|
{metricCols.map((col, i) => (
|
|
@@ -259,7 +387,7 @@ function ChartView({ chartConfig, chartData, rows, columns }) {
|
|
|
259
387
|
chartData={chartData}
|
|
260
388
|
/>
|
|
261
389
|
);
|
|
262
|
-
}
|
|
390
|
+
});
|
|
263
391
|
|
|
264
392
|
/**
|
|
265
393
|
* ResultsView - Displays query results with SQL and data table
|
|
@@ -334,15 +462,31 @@ export function ResultsView({
|
|
|
334
462
|
() => detectChartConfig(columns, rows),
|
|
335
463
|
[columns, rows],
|
|
336
464
|
);
|
|
337
|
-
const chartData = useMemo(
|
|
338
|
-
()
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
465
|
+
const { chartData, pivotedByMetric, groupValues } = useMemo(() => {
|
|
466
|
+
if (!chartConfig || !chartConfig.xCol)
|
|
467
|
+
return { chartData: [], pivotedByMetric: null, groupValues: null };
|
|
468
|
+
if (chartConfig.groupByCol) {
|
|
469
|
+
const { pivotedByMetric, groupValues } = buildPivotedData(
|
|
470
|
+
rows,
|
|
471
|
+
columns,
|
|
472
|
+
chartConfig.xCol,
|
|
473
|
+
chartConfig.groupByCol,
|
|
474
|
+
chartConfig.metricCols,
|
|
475
|
+
);
|
|
476
|
+
return {
|
|
477
|
+
chartData: pivotedByMetric[0].data,
|
|
478
|
+
pivotedByMetric,
|
|
479
|
+
groupValues,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
chartData: buildChartData(columns, rows, chartConfig.xCol),
|
|
484
|
+
pivotedByMetric: null,
|
|
485
|
+
groupValues: null,
|
|
486
|
+
};
|
|
487
|
+
}, [columns, rows, chartConfig]);
|
|
344
488
|
|
|
345
|
-
const canChart =
|
|
489
|
+
const canChart = rowCount > 0;
|
|
346
490
|
|
|
347
491
|
// Reset to table view if new results can't be charted
|
|
348
492
|
useEffect(() => {
|
|
@@ -594,6 +738,8 @@ export function ResultsView({
|
|
|
594
738
|
<ChartView
|
|
595
739
|
chartConfig={chartConfig}
|
|
596
740
|
chartData={chartData}
|
|
741
|
+
pivotedByMetric={pivotedByMetric}
|
|
742
|
+
groupValues={groupValues}
|
|
597
743
|
rows={rows}
|
|
598
744
|
columns={columns}
|
|
599
745
|
/>
|
|
@@ -499,7 +499,7 @@ describe('ResultsView', () => {
|
|
|
499
499
|
expect(chartTab).not.toHaveClass('disabled');
|
|
500
500
|
});
|
|
501
501
|
|
|
502
|
-
it('Chart tab is
|
|
502
|
+
it('Chart tab is enabled for any data with rows, showing no-data message when unchartable', () => {
|
|
503
503
|
render(
|
|
504
504
|
<ResultsView
|
|
505
505
|
{...defaultProps}
|
|
@@ -514,7 +514,12 @@ describe('ResultsView', () => {
|
|
|
514
514
|
/>,
|
|
515
515
|
);
|
|
516
516
|
const chartTab = screen.getByText('Chart').closest('button');
|
|
517
|
-
expect(chartTab).toHaveClass('disabled');
|
|
517
|
+
expect(chartTab).not.toHaveClass('disabled');
|
|
518
|
+
|
|
519
|
+
fireEvent.click(chartTab);
|
|
520
|
+
expect(
|
|
521
|
+
screen.getByText('No chartable data detected'),
|
|
522
|
+
).toBeInTheDocument();
|
|
518
523
|
});
|
|
519
524
|
|
|
520
525
|
it('switches to chart view when Chart tab is clicked (bar chart)', () => {
|
|
@@ -643,8 +648,8 @@ describe('ResultsView', () => {
|
|
|
643
648
|
expect(labels.length).toBe(3);
|
|
644
649
|
});
|
|
645
650
|
|
|
646
|
-
it('
|
|
647
|
-
// Single string column → not chartable
|
|
651
|
+
it('shows no-data message when Chart tab clicked with unchartable data', () => {
|
|
652
|
+
// Single string column → not chartable, but tab is still clickable
|
|
648
653
|
render(
|
|
649
654
|
<ResultsView
|
|
650
655
|
{...defaultProps}
|
|
@@ -662,9 +667,8 @@ describe('ResultsView', () => {
|
|
|
662
667
|
const chartTab = screen.getByText('Chart').closest('button');
|
|
663
668
|
fireEvent.click(chartTab);
|
|
664
669
|
|
|
665
|
-
// Should still be on table view
|
|
666
670
|
expect(
|
|
667
|
-
|
|
671
|
+
screen.getByText('No chartable data detected'),
|
|
668
672
|
).toBeInTheDocument();
|
|
669
673
|
});
|
|
670
674
|
|
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
Materialization Planner - Three Column Layout
|
|
3
3
|
================================= */
|
|
4
4
|
|
|
5
|
+
/* Reset native browser input styling for all planner inputs */
|
|
6
|
+
.cube-search,
|
|
7
|
+
.combobox-search,
|
|
8
|
+
.filter-input {
|
|
9
|
+
-webkit-appearance: none;
|
|
10
|
+
appearance: none;
|
|
11
|
+
box-shadow: none;
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
/* CSS Variables for theming */
|
|
6
15
|
:root {
|
|
7
16
|
--planner-bg: #f8fafc;
|
|
@@ -41,8 +41,9 @@ describe('<Root />', () => {
|
|
|
41
41
|
expect(document.title).toEqual('DataJunction');
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
// Check navigation links exist
|
|
45
|
-
expect(screen.getAllByText('
|
|
44
|
+
// Check navigation links exist
|
|
45
|
+
expect(screen.getAllByText('Catalog')).toHaveLength(1);
|
|
46
|
+
expect(screen.getAllByText('Explore')).toHaveLength(1);
|
|
46
47
|
});
|
|
47
48
|
|
|
48
49
|
it('renders Docs dropdown', async () => {
|
|
@@ -56,7 +56,7 @@ export function Root() {
|
|
|
56
56
|
<div className="menu-item here menu-here-bg menu-lg-down-accordion me-0 me-lg-2 fw-semibold">
|
|
57
57
|
<span className="menu-link">
|
|
58
58
|
<span className="menu-title">
|
|
59
|
-
<a href="/">
|
|
59
|
+
<a href="/">Catalog</a>
|
|
60
60
|
</span>
|
|
61
61
|
</span>
|
|
62
62
|
<span className="menu-link">
|
|
@@ -1305,6 +1305,28 @@ export const DataJunctionAPI = {
|
|
|
1305
1305
|
if (results.state === 'FAILED') {
|
|
1306
1306
|
throw new Error(results.errors?.[0] || 'Query execution failed');
|
|
1307
1307
|
}
|
|
1308
|
+
|
|
1309
|
+
// If FINISHED but results are empty, re-submit to /data/ a few times
|
|
1310
|
+
// before giving up (mirrors the Python client's behavior).
|
|
1311
|
+
if (!results.results?.length || !results.results[0]?.rows?.length) {
|
|
1312
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1313
|
+
await sleep(2 ** attempt * 1000);
|
|
1314
|
+
const retryResponse = await fetch(`${DJ_URL}/data/?${params}`, {
|
|
1315
|
+
credentials: 'include',
|
|
1316
|
+
});
|
|
1317
|
+
if (retryResponse.ok) {
|
|
1318
|
+
const retryResults = await retryResponse.json();
|
|
1319
|
+
if (
|
|
1320
|
+
retryResults.results?.length &&
|
|
1321
|
+
retryResults.results[0]?.rows?.length
|
|
1322
|
+
) {
|
|
1323
|
+
results = retryResults;
|
|
1324
|
+
break;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1308
1330
|
return results;
|
|
1309
1331
|
},
|
|
1310
1332
|
|
|
@@ -619,7 +619,12 @@ describe('DataJunctionAPI', () => {
|
|
|
619
619
|
it('calls data correctly', async () => {
|
|
620
620
|
const metricSelection = ['metric1'];
|
|
621
621
|
const dimensionSelection = ['dimension1'];
|
|
622
|
-
fetch.mockResponseOnce(
|
|
622
|
+
fetch.mockResponseOnce(
|
|
623
|
+
JSON.stringify({
|
|
624
|
+
state: 'FINISHED',
|
|
625
|
+
results: [{ rows: [['val']] }],
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
623
628
|
await DataJunctionAPI.data(metricSelection, dimensionSelection);
|
|
624
629
|
expect(fetch).toHaveBeenCalledWith(
|
|
625
630
|
expect.stringContaining(`${DJ_URL}/data/?`),
|
|
@@ -3004,7 +3009,11 @@ describe('DataJunctionAPI', () => {
|
|
|
3004
3009
|
it('calls data with filters array', async () => {
|
|
3005
3010
|
fetch.mockResolvedValueOnce({
|
|
3006
3011
|
ok: true,
|
|
3007
|
-
json: () =>
|
|
3012
|
+
json: () =>
|
|
3013
|
+
Promise.resolve({
|
|
3014
|
+
state: 'FINISHED',
|
|
3015
|
+
results: [{ rows: [['val']] }],
|
|
3016
|
+
}),
|
|
3008
3017
|
});
|
|
3009
3018
|
await DataJunctionAPI.data(
|
|
3010
3019
|
['metric1'],
|