datajunction-ui 0.0.44 → 0.0.45
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/__tests__/NamespaceHeader.test.jsx +349 -1
- package/src/app/pages/QueryPlannerPage/PreAggDetailsPanel.jsx +46 -1
- package/src/app/pages/QueryPlannerPage/ResultsView.jsx +281 -0
- package/src/app/pages/QueryPlannerPage/SelectionPanel.jsx +225 -100
- package/src/app/pages/QueryPlannerPage/__tests__/MetricFlowGraph.test.jsx +193 -0
- package/src/app/pages/QueryPlannerPage/__tests__/ResultsView.test.jsx +388 -0
- package/src/app/pages/QueryPlannerPage/__tests__/SelectionPanel.test.jsx +31 -51
- package/src/app/pages/QueryPlannerPage/__tests__/index.test.jsx +720 -34
- package/src/app/pages/QueryPlannerPage/index.jsx +237 -117
- package/src/app/pages/QueryPlannerPage/styles.css +765 -15
- package/src/app/services/DJService.js +29 -6
- package/src/app/services/__tests__/DJService.test.jsx +163 -0
|
@@ -429,4 +429,197 @@ describe('MetricFlowGraph Node Display', () => {
|
|
|
429
429
|
expect(screen.getByText('num_repair_orders')).toBeInTheDocument();
|
|
430
430
|
expect(screen.getByText('avg_repair_price')).toBeInTheDocument();
|
|
431
431
|
});
|
|
432
|
+
|
|
433
|
+
describe('Branch Coverage - Edge Cases', () => {
|
|
434
|
+
it('handles grain group with empty grain array', () => {
|
|
435
|
+
const grainGroupsEmptyGrain = [
|
|
436
|
+
{
|
|
437
|
+
parent_name: 'default.orders',
|
|
438
|
+
grain: [], // Empty grain array
|
|
439
|
+
components: [{ name: 'count_orders', expression: 'COUNT(*)' }],
|
|
440
|
+
},
|
|
441
|
+
];
|
|
442
|
+
|
|
443
|
+
render(
|
|
444
|
+
<MetricFlowGraph
|
|
445
|
+
grainGroups={grainGroupsEmptyGrain}
|
|
446
|
+
metricFormulas={[
|
|
447
|
+
{
|
|
448
|
+
name: 'default.metric',
|
|
449
|
+
short_name: 'metric',
|
|
450
|
+
components: ['count_orders'],
|
|
451
|
+
is_derived: false,
|
|
452
|
+
},
|
|
453
|
+
]}
|
|
454
|
+
onNodeSelect={jest.fn()}
|
|
455
|
+
/>,
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('handles grain group with no components', () => {
|
|
462
|
+
const grainGroupsNoComponents = [
|
|
463
|
+
{
|
|
464
|
+
parent_name: 'default.orders',
|
|
465
|
+
grain: ['date_id'],
|
|
466
|
+
components: [], // Empty components
|
|
467
|
+
},
|
|
468
|
+
];
|
|
469
|
+
|
|
470
|
+
render(
|
|
471
|
+
<MetricFlowGraph
|
|
472
|
+
grainGroups={grainGroupsNoComponents}
|
|
473
|
+
metricFormulas={[
|
|
474
|
+
{
|
|
475
|
+
name: 'default.metric',
|
|
476
|
+
short_name: 'metric',
|
|
477
|
+
components: [],
|
|
478
|
+
is_derived: false,
|
|
479
|
+
},
|
|
480
|
+
]}
|
|
481
|
+
onNodeSelect={jest.fn()}
|
|
482
|
+
/>,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('handles grain group with undefined components', () => {
|
|
489
|
+
const grainGroupsUndefinedComponents = [
|
|
490
|
+
{
|
|
491
|
+
parent_name: 'default.orders',
|
|
492
|
+
grain: ['date_id'],
|
|
493
|
+
// components is undefined
|
|
494
|
+
},
|
|
495
|
+
];
|
|
496
|
+
|
|
497
|
+
render(
|
|
498
|
+
<MetricFlowGraph
|
|
499
|
+
grainGroups={grainGroupsUndefinedComponents}
|
|
500
|
+
metricFormulas={[
|
|
501
|
+
{
|
|
502
|
+
name: 'default.metric',
|
|
503
|
+
short_name: 'metric',
|
|
504
|
+
components: [],
|
|
505
|
+
is_derived: false,
|
|
506
|
+
},
|
|
507
|
+
]}
|
|
508
|
+
onNodeSelect={jest.fn()}
|
|
509
|
+
/>,
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('handles metric with is_derived false', () => {
|
|
516
|
+
render(
|
|
517
|
+
<MetricFlowGraph
|
|
518
|
+
grainGroups={mockGrainGroups}
|
|
519
|
+
metricFormulas={[
|
|
520
|
+
{
|
|
521
|
+
name: 'default.simple_metric',
|
|
522
|
+
short_name: 'simple_metric',
|
|
523
|
+
combiner: 'SUM(count)',
|
|
524
|
+
is_derived: false,
|
|
525
|
+
components: ['count_orders'],
|
|
526
|
+
},
|
|
527
|
+
]}
|
|
528
|
+
onNodeSelect={jest.fn()}
|
|
529
|
+
/>,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
expect(screen.getByText('simple_metric')).toBeInTheDocument();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('handles metric with is_derived true', () => {
|
|
536
|
+
render(
|
|
537
|
+
<MetricFlowGraph
|
|
538
|
+
grainGroups={mockGrainGroups}
|
|
539
|
+
metricFormulas={[
|
|
540
|
+
{
|
|
541
|
+
name: 'default.derived_metric',
|
|
542
|
+
short_name: 'derived_metric',
|
|
543
|
+
combiner: 'SUM(a) / SUM(b)',
|
|
544
|
+
is_derived: true,
|
|
545
|
+
components: ['sum_revenue', 'count_orders'],
|
|
546
|
+
},
|
|
547
|
+
]}
|
|
548
|
+
onNodeSelect={jest.fn()}
|
|
549
|
+
/>,
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
expect(screen.getByText('derived_metric')).toBeInTheDocument();
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('handles selectedNode prop for preagg', () => {
|
|
556
|
+
render(
|
|
557
|
+
<MetricFlowGraph
|
|
558
|
+
grainGroups={mockGrainGroups}
|
|
559
|
+
metricFormulas={mockMetricFormulas}
|
|
560
|
+
onNodeSelect={jest.fn()}
|
|
561
|
+
selectedNode={{ type: 'preagg', index: 0, data: mockGrainGroups[0] }}
|
|
562
|
+
/>,
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('handles selectedNode prop for metric', () => {
|
|
569
|
+
render(
|
|
570
|
+
<MetricFlowGraph
|
|
571
|
+
grainGroups={mockGrainGroups}
|
|
572
|
+
metricFormulas={mockMetricFormulas}
|
|
573
|
+
onNodeSelect={jest.fn()}
|
|
574
|
+
selectedNode={{
|
|
575
|
+
type: 'metric',
|
|
576
|
+
index: 0,
|
|
577
|
+
data: mockMetricFormulas[0],
|
|
578
|
+
}}
|
|
579
|
+
/>,
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('handles no selectedNode', () => {
|
|
586
|
+
render(
|
|
587
|
+
<MetricFlowGraph
|
|
588
|
+
grainGroups={mockGrainGroups}
|
|
589
|
+
metricFormulas={mockMetricFormulas}
|
|
590
|
+
onNodeSelect={jest.fn()}
|
|
591
|
+
selectedNode={null}
|
|
592
|
+
/>,
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('handles grain group with undefined grain', () => {
|
|
599
|
+
const grainGroupsUndefinedGrain = [
|
|
600
|
+
{
|
|
601
|
+
parent_name: 'default.orders',
|
|
602
|
+
// grain is undefined
|
|
603
|
+
components: [{ name: 'count_orders', expression: 'COUNT(*)' }],
|
|
604
|
+
},
|
|
605
|
+
];
|
|
606
|
+
|
|
607
|
+
render(
|
|
608
|
+
<MetricFlowGraph
|
|
609
|
+
grainGroups={grainGroupsUndefinedGrain}
|
|
610
|
+
metricFormulas={[
|
|
611
|
+
{
|
|
612
|
+
name: 'default.metric',
|
|
613
|
+
short_name: 'metric',
|
|
614
|
+
components: ['count_orders'],
|
|
615
|
+
is_derived: false,
|
|
616
|
+
},
|
|
617
|
+
]}
|
|
618
|
+
onNodeSelect={jest.fn()}
|
|
619
|
+
/>,
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
expect(screen.getByTestId('react-flow')).toBeInTheDocument();
|
|
623
|
+
});
|
|
624
|
+
});
|
|
432
625
|
});
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
2
|
+
import { ResultsView } from '../ResultsView';
|
|
3
|
+
|
|
4
|
+
// Mock react-syntax-highlighter
|
|
5
|
+
jest.mock('react-syntax-highlighter', () => {
|
|
6
|
+
const MockLight = ({ children }) => (
|
|
7
|
+
<pre data-testid="syntax-highlighter">{children}</pre>
|
|
8
|
+
);
|
|
9
|
+
MockLight.registerLanguage = jest.fn();
|
|
10
|
+
return { Light: MockLight };
|
|
11
|
+
});
|
|
12
|
+
jest.mock('react-syntax-highlighter/src/styles/hljs', () => ({
|
|
13
|
+
foundation: {},
|
|
14
|
+
}));
|
|
15
|
+
jest.mock('react-syntax-highlighter/dist/esm/languages/hljs/sql', () => ({}));
|
|
16
|
+
|
|
17
|
+
// Mock clipboard API
|
|
18
|
+
const mockWriteText = jest.fn();
|
|
19
|
+
Object.assign(navigator, {
|
|
20
|
+
clipboard: {
|
|
21
|
+
writeText: mockWriteText,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('ResultsView', () => {
|
|
26
|
+
const defaultProps = {
|
|
27
|
+
sql: 'SELECT * FROM table',
|
|
28
|
+
results: {
|
|
29
|
+
results: [
|
|
30
|
+
{
|
|
31
|
+
columns: [
|
|
32
|
+
{ name: 'id', type: 'INT' },
|
|
33
|
+
{ name: 'name', type: 'STRING' },
|
|
34
|
+
],
|
|
35
|
+
rows: [
|
|
36
|
+
[1, 'Alice'],
|
|
37
|
+
[2, 'Bob'],
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
loading: false,
|
|
43
|
+
error: null,
|
|
44
|
+
elapsedTime: 1.5,
|
|
45
|
+
onBackToPlan: jest.fn(),
|
|
46
|
+
selectedMetrics: ['metric1', 'metric2'],
|
|
47
|
+
selectedDimensions: ['dim1'],
|
|
48
|
+
filters: [],
|
|
49
|
+
dialect: 'SPARK',
|
|
50
|
+
cubeName: null,
|
|
51
|
+
availability: null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
jest.clearAllMocks();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('Header', () => {
|
|
59
|
+
it('renders back to plan button', () => {
|
|
60
|
+
render(<ResultsView {...defaultProps} />);
|
|
61
|
+
expect(screen.getByText('Back to Plan')).toBeInTheDocument();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('calls onBackToPlan when back button is clicked', () => {
|
|
65
|
+
const onBackToPlan = jest.fn();
|
|
66
|
+
render(<ResultsView {...defaultProps} onBackToPlan={onBackToPlan} />);
|
|
67
|
+
|
|
68
|
+
fireEvent.click(screen.getByText('Back to Plan'));
|
|
69
|
+
expect(onBackToPlan).toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('shows row count and elapsed time', () => {
|
|
73
|
+
render(<ResultsView {...defaultProps} />);
|
|
74
|
+
// Row count appears in both header and table section
|
|
75
|
+
expect(screen.getAllByText('2 rows').length).toBeGreaterThanOrEqual(1);
|
|
76
|
+
expect(screen.getByText('1.50s')).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('shows loading state', () => {
|
|
80
|
+
render(<ResultsView {...defaultProps} loading={true} />);
|
|
81
|
+
expect(screen.getByText('Running query...')).toBeInTheDocument();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('shows error state in header', () => {
|
|
85
|
+
render(<ResultsView {...defaultProps} error="Query timeout" />);
|
|
86
|
+
expect(screen.getByText('Query failed')).toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('handles null elapsedTime', () => {
|
|
90
|
+
render(<ResultsView {...defaultProps} elapsedTime={null} />);
|
|
91
|
+
expect(screen.getAllByText('2 rows').length).toBeGreaterThanOrEqual(1);
|
|
92
|
+
// Should not show elapsed time when null
|
|
93
|
+
expect(screen.queryByText(/^\d+\.\d+s$/)).not.toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('SQL Pane', () => {
|
|
98
|
+
it('displays SQL query', () => {
|
|
99
|
+
render(<ResultsView {...defaultProps} />);
|
|
100
|
+
expect(screen.getByText('SELECT * FROM table')).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('shows SQL Query title', () => {
|
|
104
|
+
render(<ResultsView {...defaultProps} />);
|
|
105
|
+
expect(screen.getByText('SQL Query')).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('shows generating message when no SQL', () => {
|
|
109
|
+
render(<ResultsView {...defaultProps} sql={null} />);
|
|
110
|
+
expect(screen.getByText('Generating SQL...')).toBeInTheDocument();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('renders copy button', () => {
|
|
114
|
+
render(<ResultsView {...defaultProps} />);
|
|
115
|
+
expect(screen.getByText('Copy')).toBeInTheDocument();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('copies SQL to clipboard when copy button clicked', async () => {
|
|
119
|
+
render(<ResultsView {...defaultProps} />);
|
|
120
|
+
|
|
121
|
+
fireEvent.click(screen.getByText('Copy'));
|
|
122
|
+
|
|
123
|
+
expect(mockWriteText).toHaveBeenCalledWith('SELECT * FROM table');
|
|
124
|
+
expect(screen.getByText('✓ Copied')).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('disables copy button when no SQL', () => {
|
|
128
|
+
render(<ResultsView {...defaultProps} sql={null} />);
|
|
129
|
+
expect(screen.getByText('Copy')).toBeDisabled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('shows materialization info when cubeName is provided', () => {
|
|
133
|
+
render(
|
|
134
|
+
<ResultsView
|
|
135
|
+
{...defaultProps}
|
|
136
|
+
cubeName="test_cube"
|
|
137
|
+
availability={{
|
|
138
|
+
catalog: 'catalog',
|
|
139
|
+
schema_: 'schema',
|
|
140
|
+
table: 'table',
|
|
141
|
+
validThroughTs: 1705363200000, // Jan 16, 2024
|
|
142
|
+
}}
|
|
143
|
+
/>,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(screen.getByText(/Using materialized cube/)).toBeInTheDocument();
|
|
147
|
+
expect(screen.getByText(/Valid thru/)).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('shows materialization info without availability date', () => {
|
|
151
|
+
render(<ResultsView {...defaultProps} cubeName="test_cube" />);
|
|
152
|
+
|
|
153
|
+
expect(screen.getByText(/Using materialized cube/)).toBeInTheDocument();
|
|
154
|
+
expect(screen.queryByText(/Valid thru/)).not.toBeInTheDocument();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('Loading State', () => {
|
|
159
|
+
it('shows loading spinner and message', () => {
|
|
160
|
+
render(<ResultsView {...defaultProps} loading={true} />);
|
|
161
|
+
|
|
162
|
+
expect(screen.getByText('Executing query...')).toBeInTheDocument();
|
|
163
|
+
expect(
|
|
164
|
+
screen.getByText(/Querying 2 metric\(s\) with 1 dimension\(s\)/),
|
|
165
|
+
).toBeInTheDocument();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('Error State', () => {
|
|
170
|
+
it('shows error message', () => {
|
|
171
|
+
render(<ResultsView {...defaultProps} error="Connection failed" />);
|
|
172
|
+
|
|
173
|
+
expect(screen.getByText('Query Failed')).toBeInTheDocument();
|
|
174
|
+
expect(screen.getByText('Connection failed')).toBeInTheDocument();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('shows back to plan button in error state', () => {
|
|
178
|
+
const onBackToPlan = jest.fn();
|
|
179
|
+
render(
|
|
180
|
+
<ResultsView
|
|
181
|
+
{...defaultProps}
|
|
182
|
+
error="Error"
|
|
183
|
+
onBackToPlan={onBackToPlan}
|
|
184
|
+
/>,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// There are two back buttons - one in header, one in error state
|
|
188
|
+
const backButtons = screen.getAllByText('Back to Plan');
|
|
189
|
+
expect(backButtons.length).toBe(2);
|
|
190
|
+
|
|
191
|
+
fireEvent.click(backButtons[1]);
|
|
192
|
+
expect(onBackToPlan).toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('Results Table', () => {
|
|
197
|
+
it('renders table with columns and rows', () => {
|
|
198
|
+
render(<ResultsView {...defaultProps} />);
|
|
199
|
+
|
|
200
|
+
expect(screen.getByText('id')).toBeInTheDocument();
|
|
201
|
+
expect(screen.getByText('name')).toBeInTheDocument();
|
|
202
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
203
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('shows column types', () => {
|
|
207
|
+
render(<ResultsView {...defaultProps} />);
|
|
208
|
+
|
|
209
|
+
expect(screen.getByText('INT')).toBeInTheDocument();
|
|
210
|
+
expect(screen.getByText('STRING')).toBeInTheDocument();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('shows row count in table header', () => {
|
|
214
|
+
render(<ResultsView {...defaultProps} />);
|
|
215
|
+
|
|
216
|
+
// Row count appears in both header and table section
|
|
217
|
+
const rowCounts = screen.getAllByText('2 rows');
|
|
218
|
+
expect(rowCounts.length).toBeGreaterThanOrEqual(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('shows empty state when no results', () => {
|
|
222
|
+
render(
|
|
223
|
+
<ResultsView
|
|
224
|
+
{...defaultProps}
|
|
225
|
+
results={{ results: [{ columns: [], rows: [] }] }}
|
|
226
|
+
/>,
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect(screen.getByText('No results returned')).toBeInTheDocument();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('handles null values in cells', () => {
|
|
233
|
+
render(
|
|
234
|
+
<ResultsView
|
|
235
|
+
{...defaultProps}
|
|
236
|
+
results={{
|
|
237
|
+
results: [
|
|
238
|
+
{
|
|
239
|
+
columns: [{ name: 'value', type: 'STRING' }],
|
|
240
|
+
rows: [[null], ['data']],
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
}}
|
|
244
|
+
/>,
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
expect(screen.getByText('NULL')).toBeInTheDocument();
|
|
248
|
+
expect(screen.getByText('data')).toBeInTheDocument();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('displays filters as chips', () => {
|
|
252
|
+
render(
|
|
253
|
+
<ResultsView
|
|
254
|
+
{...defaultProps}
|
|
255
|
+
filters={["date >= '2024-01-01'", "status = 'active'"]}
|
|
256
|
+
/>,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(screen.getByText("date >= '2024-01-01'")).toBeInTheDocument();
|
|
260
|
+
expect(screen.getByText("status = 'active'")).toBeInTheDocument();
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe('Sorting', () => {
|
|
265
|
+
it('sorts by column when header is clicked', () => {
|
|
266
|
+
render(<ResultsView {...defaultProps} />);
|
|
267
|
+
|
|
268
|
+
// Click on 'name' column header to sort
|
|
269
|
+
const nameHeader = screen.getByText('name').closest('th');
|
|
270
|
+
fireEvent.click(nameHeader);
|
|
271
|
+
|
|
272
|
+
// First row should now be Alice (ascending)
|
|
273
|
+
const rows = screen.getAllByRole('row');
|
|
274
|
+
expect(rows[1]).toHaveTextContent('Alice');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('toggles sort direction on second click', () => {
|
|
278
|
+
render(<ResultsView {...defaultProps} />);
|
|
279
|
+
|
|
280
|
+
const nameHeader = screen.getByText('name').closest('th');
|
|
281
|
+
|
|
282
|
+
// First click - ascending
|
|
283
|
+
fireEvent.click(nameHeader);
|
|
284
|
+
let rows = screen.getAllByRole('row');
|
|
285
|
+
expect(rows[1]).toHaveTextContent('Alice');
|
|
286
|
+
|
|
287
|
+
// Second click - descending
|
|
288
|
+
fireEvent.click(nameHeader);
|
|
289
|
+
rows = screen.getAllByRole('row');
|
|
290
|
+
expect(rows[1]).toHaveTextContent('Bob');
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('shows active sort indicator', () => {
|
|
294
|
+
render(<ResultsView {...defaultProps} />);
|
|
295
|
+
|
|
296
|
+
const nameHeader = screen.getByText('name').closest('th');
|
|
297
|
+
fireEvent.click(nameHeader);
|
|
298
|
+
|
|
299
|
+
expect(nameHeader).toHaveClass('sorted');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('sorts numeric columns correctly', () => {
|
|
303
|
+
render(
|
|
304
|
+
<ResultsView
|
|
305
|
+
{...defaultProps}
|
|
306
|
+
results={{
|
|
307
|
+
results: [
|
|
308
|
+
{
|
|
309
|
+
columns: [{ name: 'count', type: 'INT' }],
|
|
310
|
+
rows: [[10], [2], [100]],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
}}
|
|
314
|
+
/>,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const countHeader = screen.getByText('count').closest('th');
|
|
318
|
+
fireEvent.click(countHeader);
|
|
319
|
+
|
|
320
|
+
const cells = screen.getAllByRole('cell');
|
|
321
|
+
expect(cells[0]).toHaveTextContent('2');
|
|
322
|
+
expect(cells[1]).toHaveTextContent('10');
|
|
323
|
+
expect(cells[2]).toHaveTextContent('100');
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('handles null values in sorting - nulls go last', () => {
|
|
327
|
+
render(
|
|
328
|
+
<ResultsView
|
|
329
|
+
{...defaultProps}
|
|
330
|
+
results={{
|
|
331
|
+
results: [
|
|
332
|
+
{
|
|
333
|
+
columns: [{ name: 'value', type: 'STRING' }],
|
|
334
|
+
rows: [[null], ['b'], ['a']],
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
}}
|
|
338
|
+
/>,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const valueHeader = screen.getByText('value').closest('th');
|
|
342
|
+
fireEvent.click(valueHeader);
|
|
343
|
+
|
|
344
|
+
const cells = screen.getAllByRole('cell');
|
|
345
|
+
expect(cells[0]).toHaveTextContent('a');
|
|
346
|
+
expect(cells[1]).toHaveTextContent('b');
|
|
347
|
+
expect(cells[2]).toHaveTextContent('NULL');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('Edge Cases', () => {
|
|
352
|
+
it('handles missing results gracefully', () => {
|
|
353
|
+
render(<ResultsView {...defaultProps} results={null} />);
|
|
354
|
+
|
|
355
|
+
// Row count appears in both header and table section
|
|
356
|
+
expect(screen.getAllByText('0 rows').length).toBeGreaterThanOrEqual(1);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('handles empty results object', () => {
|
|
360
|
+
render(<ResultsView {...defaultProps} results={{}} />);
|
|
361
|
+
|
|
362
|
+
// Row count appears in both header and table section
|
|
363
|
+
expect(screen.getAllByText('0 rows').length).toBeGreaterThanOrEqual(1);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('formats large row counts with locale', () => {
|
|
367
|
+
const manyRows = Array.from({ length: 1000 }, (_, i) => [i]);
|
|
368
|
+
render(
|
|
369
|
+
<ResultsView
|
|
370
|
+
{...defaultProps}
|
|
371
|
+
results={{
|
|
372
|
+
results: [
|
|
373
|
+
{
|
|
374
|
+
columns: [{ name: 'id', type: 'INT' }],
|
|
375
|
+
rows: manyRows,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
}}
|
|
379
|
+
/>,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Should show "1,000 rows" with locale formatting (appears in both header and table)
|
|
383
|
+
expect(screen.getAllByText('1,000 rows').length).toBeGreaterThanOrEqual(
|
|
384
|
+
1,
|
|
385
|
+
);
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
});
|