datajunction-ui 0.0.95 → 0.0.96

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.95",
3
+ "version": "0.0.96",
4
4
  "description": "DataJunction UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -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
- <form
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
- {this.props.name}
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('prevents form submission', async () => {
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 form = container.querySelector('form');
283
-
284
- const submitEvent = new Event('submit', {
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
- height: 50%;
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: rgba(244, 244, 244, 0.8);
11
- border-radius: 1rem;
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
- navigate(`/planner?metrics=${encodeURIComponent(name)}`);
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
- display: node?.type === 'metric',
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
- '#3b82f6',
21
- '#a2283e',
22
- '#059669',
23
- '#d97706',
24
- '#8b5cf6',
25
- '#0ea5e9',
26
- '#ec4899',
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 (handles integer time cols like week/year)
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) return { type: 'line', xCol, metricCols };
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
- // String/categorical dimension → bar chart
85
- if (nonNumericCols.length > 0 && numericCols.length > 0) {
86
- const xCol = nonNumericCols[0];
87
- const metricCols = numericCols.filter(c => c.idx !== xCol.idx);
88
- if (metricCols.length > 0) return { type: 'bar', xCol, metricCols };
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 xInterval = type === 'line' ? 'preserveStartEnd' : 0;
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
- {metricCols.map((col, i) => (
266
+ {keys.map((key, i) => (
187
267
  <Line
188
- key={col.idx}
268
+ key={key}
189
269
  type="monotone"
190
- dataKey={col.name}
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
- {metricCols.map((col, i) => (
294
+ {keys.map((key, i) => (
214
295
  <Bar
215
- key={col.idx}
216
- dataKey={col.name}
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
- if (useSmallMultiples) {
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
- chartConfig && chartConfig.xCol
340
- ? buildChartData(columns, rows, chartConfig.xCol)
341
- : [],
342
- [columns, rows, chartConfig],
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 = chartConfig !== null && rowCount > 0;
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 disabled when data is not chartable (string-only columns)', () => {
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('does not click disabled Chart tab (canChart false)', () => {
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
- document.querySelector('.results-table-wrapper'),
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 (two "Explore" links: catalog browser + planner)
45
- expect(screen.getAllByText('Explore')).toHaveLength(2);
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="/">Explore</a>
59
+ <a href="/">Catalog</a>
60
60
  </span>
61
61
  </span>
62
62
  <span className="menu-link">