@object-ui/plugin-grid 3.3.0 → 3.3.2
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/CHANGELOG.md +23 -0
- package/README.md +21 -1
- package/dist/index.js +631 -599
- package/dist/index.umd.cjs +8 -8
- package/package.json +44 -12
- package/.turbo/turbo-build.log +0 -32
- package/src/FormulaBar.tsx +0 -151
- package/src/GroupRow.tsx +0 -69
- package/src/ImportWizard.tsx +0 -412
- package/src/InlineEditing.tsx +0 -235
- package/src/ListColumnExtensions.test.tsx +0 -373
- package/src/ListColumnSchema.test.ts +0 -88
- package/src/ObjectGrid.EdgeCases.stories.tsx +0 -147
- package/src/ObjectGrid.msw.test.tsx +0 -130
- package/src/ObjectGrid.stories.tsx +0 -139
- package/src/ObjectGrid.tsx +0 -1598
- package/src/SplitPaneGrid.tsx +0 -120
- package/src/VirtualGrid.tsx +0 -183
- package/src/__tests__/GroupRow.test.tsx +0 -206
- package/src/__tests__/ImportPreview.test.tsx +0 -171
- package/src/__tests__/InlineEditing.test.tsx +0 -360
- package/src/__tests__/VirtualGrid.test.tsx +0 -438
- package/src/__tests__/accessibility.test.tsx +0 -254
- package/src/__tests__/accessorKey-inference.test.tsx +0 -132
- package/src/__tests__/airtable-style.test.tsx +0 -508
- package/src/__tests__/column-features.test.tsx +0 -490
- package/src/__tests__/grid-export.test.tsx +0 -121
- package/src/__tests__/mobile-card-view.test.tsx +0 -355
- package/src/__tests__/objectdef-enrichment.test.tsx +0 -566
- package/src/__tests__/performance-benchmark.test.tsx +0 -182
- package/src/__tests__/phase11-features.test.tsx +0 -418
- package/src/__tests__/row-bulk-actions.test.tsx +0 -413
- package/src/__tests__/row-height.test.tsx +0 -160
- package/src/__tests__/useGroupedData.test.ts +0 -165
- package/src/__tests__/view-states.test.tsx +0 -203
- package/src/components/BulkActionBar.tsx +0 -66
- package/src/components/RowActionMenu.tsx +0 -91
- package/src/index.test.tsx +0 -29
- package/src/index.tsx +0 -99
- package/src/useCellClipboard.ts +0 -136
- package/src/useColumnSummary.ts +0 -128
- package/src/useGradientColor.ts +0 -103
- package/src/useGroupReorder.ts +0 -123
- package/src/useGroupedData.ts +0 -187
- package/src/useRowColor.ts +0 -74
- package/tsconfig.json +0 -9
- package/vite.config.ts +0 -58
- package/vitest.config.ts +0 -13
- package/vitest.setup.ts +0 -1
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*
|
|
8
|
-
* Performance benchmark tests for VirtualGrid.
|
|
9
|
-
* Part of P2.4 Performance at Scale roadmap.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
13
|
-
import { render, cleanup } from '@testing-library/react';
|
|
14
|
-
import '@testing-library/jest-dom';
|
|
15
|
-
import React from 'react';
|
|
16
|
-
import type { VirtualGridColumn, VirtualGridProps } from '../VirtualGrid';
|
|
17
|
-
|
|
18
|
-
// --- Mock @tanstack/react-virtual ---
|
|
19
|
-
// Cap the visible window to simulate virtualisation (only render a subset)
|
|
20
|
-
const VISIBLE_WINDOW = 50;
|
|
21
|
-
|
|
22
|
-
vi.mock('@tanstack/react-virtual', () => ({
|
|
23
|
-
useVirtualizer: (opts: any) => {
|
|
24
|
-
const count: number = opts.count;
|
|
25
|
-
const size: number = opts.estimateSize();
|
|
26
|
-
const visible = Math.min(count, VISIBLE_WINDOW);
|
|
27
|
-
const items = [];
|
|
28
|
-
for (let i = 0; i < visible; i++) {
|
|
29
|
-
items.push({ index: i, key: String(i), start: i * size, size });
|
|
30
|
-
}
|
|
31
|
-
return {
|
|
32
|
-
getVirtualItems: () => items,
|
|
33
|
-
getTotalSize: () => count * size,
|
|
34
|
-
};
|
|
35
|
-
},
|
|
36
|
-
}));
|
|
37
|
-
|
|
38
|
-
// --- Data generators ---
|
|
39
|
-
function generateRows(count: number) {
|
|
40
|
-
const rows = [];
|
|
41
|
-
for (let i = 0; i < count; i++) {
|
|
42
|
-
rows.push({
|
|
43
|
-
id: i,
|
|
44
|
-
name: `User ${i}`,
|
|
45
|
-
email: `user${i}@example.com`,
|
|
46
|
-
department: `Dept ${i % 10}`,
|
|
47
|
-
salary: 50000 + (i * 100),
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
return rows;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const benchmarkColumns: VirtualGridColumn[] = [
|
|
54
|
-
{ header: 'ID', accessorKey: 'id' },
|
|
55
|
-
{ header: 'Name', accessorKey: 'name' },
|
|
56
|
-
{ header: 'Email', accessorKey: 'email' },
|
|
57
|
-
{ header: 'Department', accessorKey: 'department' },
|
|
58
|
-
{ header: 'Salary', accessorKey: 'salary', align: 'right' },
|
|
59
|
-
];
|
|
60
|
-
|
|
61
|
-
type VirtualGridComponent = React.FC<VirtualGridProps>;
|
|
62
|
-
|
|
63
|
-
let VirtualGrid: VirtualGridComponent;
|
|
64
|
-
|
|
65
|
-
beforeEach(async () => {
|
|
66
|
-
cleanup();
|
|
67
|
-
vi.resetModules();
|
|
68
|
-
const mod = await import('../VirtualGrid');
|
|
69
|
-
VirtualGrid = mod.VirtualGrid;
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
function renderGrid(overrides: Partial<VirtualGridProps> = {}) {
|
|
73
|
-
const props: VirtualGridProps = {
|
|
74
|
-
data: [],
|
|
75
|
-
columns: benchmarkColumns,
|
|
76
|
-
...overrides,
|
|
77
|
-
};
|
|
78
|
-
return render(<VirtualGrid {...props} />);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// =========================================================================
|
|
82
|
-
// Performance Benchmarks
|
|
83
|
-
// =========================================================================
|
|
84
|
-
|
|
85
|
-
describe('VirtualGrid: performance benchmarks', () => {
|
|
86
|
-
it('renders 1,000 rows under 500ms', () => {
|
|
87
|
-
const data = generateRows(1_000);
|
|
88
|
-
|
|
89
|
-
const start = performance.now();
|
|
90
|
-
const { container } = renderGrid({ data });
|
|
91
|
-
const elapsed = performance.now() - start;
|
|
92
|
-
|
|
93
|
-
expect(container.querySelector('.overflow-auto')).toBeInTheDocument();
|
|
94
|
-
expect(elapsed).toBeLessThan(500);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('renders 10,000 rows under 2,000ms', () => {
|
|
98
|
-
const data = generateRows(10_000);
|
|
99
|
-
|
|
100
|
-
const start = performance.now();
|
|
101
|
-
const { container } = renderGrid({ data });
|
|
102
|
-
const elapsed = performance.now() - start;
|
|
103
|
-
|
|
104
|
-
expect(container.querySelector('.overflow-auto')).toBeInTheDocument();
|
|
105
|
-
expect(elapsed).toBeLessThan(2_000);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('renders 50,000 rows without crashing', () => {
|
|
109
|
-
const data = generateRows(50_000);
|
|
110
|
-
|
|
111
|
-
const start = performance.now();
|
|
112
|
-
const { container } = renderGrid({ data });
|
|
113
|
-
const elapsed = performance.now() - start;
|
|
114
|
-
|
|
115
|
-
expect(container.querySelector('.overflow-auto')).toBeInTheDocument();
|
|
116
|
-
// Virtual scrolling should keep render time manageable even at 50K
|
|
117
|
-
expect(elapsed).toBeLessThan(10_000);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it('data generation for 10,000 rows is fast (< 200ms)', () => {
|
|
121
|
-
const start = performance.now();
|
|
122
|
-
const data = generateRows(10_000);
|
|
123
|
-
const elapsed = performance.now() - start;
|
|
124
|
-
|
|
125
|
-
expect(data).toHaveLength(10_000);
|
|
126
|
-
expect(elapsed).toBeLessThan(200);
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
// =========================================================================
|
|
131
|
-
// Scaling characteristics
|
|
132
|
-
// =========================================================================
|
|
133
|
-
|
|
134
|
-
describe('VirtualGrid: scaling characteristics', () => {
|
|
135
|
-
it('shows correct row count in footer for large dataset', () => {
|
|
136
|
-
const data = generateRows(1_000);
|
|
137
|
-
renderGrid({ data });
|
|
138
|
-
|
|
139
|
-
// Footer displays total count regardless of virtualised window
|
|
140
|
-
expect(document.body.textContent).toContain('1000');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it('renders with many columns (20+) without degradation', () => {
|
|
144
|
-
const columns: VirtualGridColumn[] = Array.from({ length: 20 }, (_, i) => ({
|
|
145
|
-
header: `Col ${i}`,
|
|
146
|
-
accessorKey: `field${i}`,
|
|
147
|
-
}));
|
|
148
|
-
const data = Array.from({ length: 500 }, (_, row) =>
|
|
149
|
-
Object.fromEntries(columns.map((c) => [c.accessorKey, `R${row}-${c.accessorKey}`])),
|
|
150
|
-
);
|
|
151
|
-
|
|
152
|
-
const start = performance.now();
|
|
153
|
-
const { container } = renderGrid({ columns, data });
|
|
154
|
-
const elapsed = performance.now() - start;
|
|
155
|
-
|
|
156
|
-
expect(container.querySelector('.overflow-auto')).toBeInTheDocument();
|
|
157
|
-
expect(elapsed).toBeLessThan(2_000);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it('custom cell renderers do not drastically increase render time for 1,000 rows', () => {
|
|
161
|
-
const columnsWithRenderers: VirtualGridColumn[] = [
|
|
162
|
-
{
|
|
163
|
-
header: 'Name',
|
|
164
|
-
accessorKey: 'name',
|
|
165
|
-
cell: (value: string) => <strong>{value}</strong>,
|
|
166
|
-
},
|
|
167
|
-
{
|
|
168
|
-
header: 'Email',
|
|
169
|
-
accessorKey: 'email',
|
|
170
|
-
cell: (value: string) => <a href={`mailto:${value}`}>{value}</a>,
|
|
171
|
-
},
|
|
172
|
-
{ header: 'Department', accessorKey: 'department' },
|
|
173
|
-
];
|
|
174
|
-
const data = generateRows(1_000);
|
|
175
|
-
|
|
176
|
-
const start = performance.now();
|
|
177
|
-
renderGrid({ columns: columnsWithRenderers, data });
|
|
178
|
-
const elapsed = performance.now() - start;
|
|
179
|
-
|
|
180
|
-
expect(elapsed).toBeLessThan(1_000);
|
|
181
|
-
});
|
|
182
|
-
});
|
|
@@ -1,418 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Phase 11 – Grid & Table Excellence L2/L3 feature tests.
|
|
3
|
-
*
|
|
4
|
-
* Covers: useCellClipboard, useGradientColor, useGroupReorder,
|
|
5
|
-
* FormulaBar, SplitPaneGrid, useGroupedData (aggregations).
|
|
6
|
-
*/
|
|
7
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
-
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
9
|
-
import { renderHook } from '@testing-library/react';
|
|
10
|
-
import '@testing-library/jest-dom';
|
|
11
|
-
import React from 'react';
|
|
12
|
-
|
|
13
|
-
import { useCellClipboard } from '../useCellClipboard';
|
|
14
|
-
import { useGradientColor } from '../useGradientColor';
|
|
15
|
-
import { useGroupReorder } from '../useGroupReorder';
|
|
16
|
-
import { FormulaBar } from '../FormulaBar';
|
|
17
|
-
import { SplitPaneGrid } from '../SplitPaneGrid';
|
|
18
|
-
import { useGroupedData } from '../useGroupedData';
|
|
19
|
-
import type { AggregationConfig } from '../useGroupedData';
|
|
20
|
-
|
|
21
|
-
// ---------------------------------------------------------------------------
|
|
22
|
-
// Clipboard mock
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
let clipboardText = '';
|
|
25
|
-
const clipboardMock = {
|
|
26
|
-
writeText: vi.fn((text: string) => {
|
|
27
|
-
clipboardText = text;
|
|
28
|
-
return Promise.resolve();
|
|
29
|
-
}),
|
|
30
|
-
readText: vi.fn(() => Promise.resolve(clipboardText)),
|
|
31
|
-
};
|
|
32
|
-
Object.defineProperty(navigator, 'clipboard', {
|
|
33
|
-
value: clipboardMock,
|
|
34
|
-
writable: true,
|
|
35
|
-
configurable: true,
|
|
36
|
-
});
|
|
37
|
-
beforeEach(() => {
|
|
38
|
-
clipboardText = '';
|
|
39
|
-
clipboardMock.writeText.mockClear();
|
|
40
|
-
clipboardMock.readText.mockClear();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
// =========================================================================
|
|
44
|
-
// useCellClipboard
|
|
45
|
-
// =========================================================================
|
|
46
|
-
describe('useCellClipboard', () => {
|
|
47
|
-
const data = [
|
|
48
|
-
{ name: 'Alice', age: '30', city: 'NYC' },
|
|
49
|
-
{ name: 'Bob', age: '25', city: 'LA' },
|
|
50
|
-
{ name: 'Carol', age: '35', city: 'SF' },
|
|
51
|
-
];
|
|
52
|
-
const columns = ['name', 'age', 'city'];
|
|
53
|
-
|
|
54
|
-
it('returns initial empty state', () => {
|
|
55
|
-
const { result } = renderHook(() =>
|
|
56
|
-
useCellClipboard({ data, columns }),
|
|
57
|
-
);
|
|
58
|
-
expect(result.current.selectedRange).toBeNull();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('setSelectedRange updates range', () => {
|
|
62
|
-
const { result } = renderHook(() =>
|
|
63
|
-
useCellClipboard({ data, columns }),
|
|
64
|
-
);
|
|
65
|
-
act(() => {
|
|
66
|
-
result.current.setSelectedRange({ startRow: 0, startCol: 0, endRow: 0, endCol: 0 });
|
|
67
|
-
});
|
|
68
|
-
expect(result.current.selectedRange).toEqual({
|
|
69
|
-
startRow: 0, startCol: 0, endRow: 0, endCol: 0,
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('clearSelection resets to null', () => {
|
|
74
|
-
const { result } = renderHook(() =>
|
|
75
|
-
useCellClipboard({ data, columns }),
|
|
76
|
-
);
|
|
77
|
-
act(() => {
|
|
78
|
-
result.current.setSelectedRange({ startRow: 0, startCol: 0, endRow: 1, endCol: 1 });
|
|
79
|
-
});
|
|
80
|
-
act(() => {
|
|
81
|
-
result.current.setSelectedRange(null);
|
|
82
|
-
});
|
|
83
|
-
expect(result.current.selectedRange).toBeNull();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('onCopy writes to clipboard for single cell', async () => {
|
|
87
|
-
const { result } = renderHook(() =>
|
|
88
|
-
useCellClipboard({ data, columns }),
|
|
89
|
-
);
|
|
90
|
-
act(() => {
|
|
91
|
-
result.current.setSelectedRange({ startRow: 0, startCol: 0, endRow: 0, endCol: 0 });
|
|
92
|
-
});
|
|
93
|
-
act(() => {
|
|
94
|
-
result.current.onCopy();
|
|
95
|
-
});
|
|
96
|
-
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Alice');
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('onCopy writes tab-separated values for range', () => {
|
|
100
|
-
const { result } = renderHook(() =>
|
|
101
|
-
useCellClipboard({ data, columns }),
|
|
102
|
-
);
|
|
103
|
-
act(() => {
|
|
104
|
-
result.current.setSelectedRange({ startRow: 0, startCol: 0, endRow: 1, endCol: 2 });
|
|
105
|
-
});
|
|
106
|
-
act(() => {
|
|
107
|
-
result.current.onCopy();
|
|
108
|
-
});
|
|
109
|
-
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
|
110
|
-
'Alice\t30\tNYC\nBob\t25\tLA',
|
|
111
|
-
);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it('onPaste reads from clipboard and calls callback', async () => {
|
|
115
|
-
const onPaste = vi.fn();
|
|
116
|
-
clipboardText = 'Dave\t40';
|
|
117
|
-
const { result } = renderHook(() =>
|
|
118
|
-
useCellClipboard({ data, columns, onPaste }),
|
|
119
|
-
);
|
|
120
|
-
act(() => {
|
|
121
|
-
result.current.setSelectedRange({ startRow: 0, startCol: 0, endRow: 0, endCol: 1 });
|
|
122
|
-
});
|
|
123
|
-
await act(async () => {
|
|
124
|
-
result.current.onPaste();
|
|
125
|
-
// Let the clipboard promise resolve
|
|
126
|
-
await Promise.resolve();
|
|
127
|
-
});
|
|
128
|
-
expect(onPaste).toHaveBeenCalledWith([
|
|
129
|
-
{ rowIndex: 0, field: 'name', value: 'Dave' },
|
|
130
|
-
{ rowIndex: 0, field: 'age', value: '40' },
|
|
131
|
-
]);
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
// =========================================================================
|
|
136
|
-
// useGradientColor
|
|
137
|
-
// =========================================================================
|
|
138
|
-
describe('useGradientColor', () => {
|
|
139
|
-
const data = [
|
|
140
|
-
{ score: 0 },
|
|
141
|
-
{ score: 50 },
|
|
142
|
-
{ score: 100 },
|
|
143
|
-
];
|
|
144
|
-
|
|
145
|
-
it('returns undefined when value is non-numeric', () => {
|
|
146
|
-
const { result } = renderHook(() =>
|
|
147
|
-
useGradientColor({ field: 'score', data }),
|
|
148
|
-
);
|
|
149
|
-
expect(result.current({ score: 'abc' })).toBeUndefined();
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
it('returns gradient class for min value', () => {
|
|
153
|
-
const { result } = renderHook(() =>
|
|
154
|
-
useGradientColor({ field: 'score', data }),
|
|
155
|
-
);
|
|
156
|
-
expect(result.current({ score: 0 })).toBe('bg-green-100');
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it('returns gradient class for max value', () => {
|
|
160
|
-
const { result } = renderHook(() =>
|
|
161
|
-
useGradientColor({ field: 'score', data }),
|
|
162
|
-
);
|
|
163
|
-
expect(result.current({ score: 100 })).toBe('bg-red-100');
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('returns mid-range gradient', () => {
|
|
167
|
-
const { result } = renderHook(() =>
|
|
168
|
-
useGradientColor({ field: 'score', data }),
|
|
169
|
-
);
|
|
170
|
-
expect(result.current({ score: 50 })).toBe('bg-yellow-100');
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('handles empty data array', () => {
|
|
174
|
-
const { result } = renderHook(() =>
|
|
175
|
-
useGradientColor({ field: 'score', data: [] }),
|
|
176
|
-
);
|
|
177
|
-
// min === max === 0, so first stop returned
|
|
178
|
-
expect(result.current({ score: 0 })).toBe('bg-green-100');
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
it('handles custom color stops', () => {
|
|
182
|
-
const stops = [
|
|
183
|
-
{ position: 0, className: 'bg-blue-100' },
|
|
184
|
-
{ position: 1, className: 'bg-purple-100' },
|
|
185
|
-
];
|
|
186
|
-
const { result } = renderHook(() =>
|
|
187
|
-
useGradientColor({ field: 'score', data, stops }),
|
|
188
|
-
);
|
|
189
|
-
expect(result.current({ score: 0 })).toBe('bg-blue-100');
|
|
190
|
-
expect(result.current({ score: 100 })).toBe('bg-purple-100');
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// =========================================================================
|
|
195
|
-
// useGroupReorder
|
|
196
|
-
// =========================================================================
|
|
197
|
-
describe('useGroupReorder', () => {
|
|
198
|
-
const groupKeys = ['alpha', 'beta', 'gamma'];
|
|
199
|
-
|
|
200
|
-
it('initializes with default order', () => {
|
|
201
|
-
const { result } = renderHook(() =>
|
|
202
|
-
useGroupReorder({ groupKeys }),
|
|
203
|
-
);
|
|
204
|
-
expect(result.current.groupOrder).toEqual(['alpha', 'beta', 'gamma']);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
it('moveGroup reorders correctly', () => {
|
|
208
|
-
const { result } = renderHook(() =>
|
|
209
|
-
useGroupReorder({ groupKeys }),
|
|
210
|
-
);
|
|
211
|
-
act(() => {
|
|
212
|
-
result.current.moveGroup(0, 2);
|
|
213
|
-
});
|
|
214
|
-
expect(result.current.groupOrder).toEqual(['beta', 'gamma', 'alpha']);
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it('handles drag start and end events', () => {
|
|
218
|
-
const { result } = renderHook(() =>
|
|
219
|
-
useGroupReorder({ groupKeys }),
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
const mockEvent = {
|
|
223
|
-
dataTransfer: { effectAllowed: '', setData: vi.fn() },
|
|
224
|
-
} as unknown as React.DragEvent;
|
|
225
|
-
|
|
226
|
-
act(() => {
|
|
227
|
-
result.current.onDragStart(mockEvent, 'beta');
|
|
228
|
-
});
|
|
229
|
-
expect(result.current.draggingKey).toBe('beta');
|
|
230
|
-
|
|
231
|
-
act(() => {
|
|
232
|
-
result.current.onDragEnd();
|
|
233
|
-
});
|
|
234
|
-
expect(result.current.draggingKey).toBeNull();
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it('returns isDragging state', () => {
|
|
238
|
-
const { result } = renderHook(() =>
|
|
239
|
-
useGroupReorder({ groupKeys }),
|
|
240
|
-
);
|
|
241
|
-
// Initially not dragging
|
|
242
|
-
expect(result.current.draggingKey).toBeNull();
|
|
243
|
-
|
|
244
|
-
const mockEvent = {
|
|
245
|
-
dataTransfer: { effectAllowed: '', setData: vi.fn() },
|
|
246
|
-
} as unknown as React.DragEvent;
|
|
247
|
-
|
|
248
|
-
act(() => {
|
|
249
|
-
result.current.onDragStart(mockEvent, 'gamma');
|
|
250
|
-
});
|
|
251
|
-
expect(result.current.draggingKey).toBe('gamma');
|
|
252
|
-
});
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// =========================================================================
|
|
256
|
-
// FormulaBar
|
|
257
|
-
// =========================================================================
|
|
258
|
-
describe('FormulaBar', () => {
|
|
259
|
-
it('renders current cell value', () => {
|
|
260
|
-
render(<FormulaBar value="Hello" />);
|
|
261
|
-
const input = screen.getByRole('textbox');
|
|
262
|
-
expect(input).toHaveValue('Hello');
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it('enters edit mode on click', () => {
|
|
266
|
-
render(<FormulaBar value="Hello" />);
|
|
267
|
-
const input = screen.getByRole('textbox');
|
|
268
|
-
expect(input).toHaveAttribute('readonly');
|
|
269
|
-
fireEvent.click(input);
|
|
270
|
-
expect(input).not.toHaveAttribute('readonly');
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('confirms on Enter key', () => {
|
|
274
|
-
const onConfirm = vi.fn();
|
|
275
|
-
render(<FormulaBar value="Hello" onConfirm={onConfirm} />);
|
|
276
|
-
const input = screen.getByRole('textbox');
|
|
277
|
-
fireEvent.click(input);
|
|
278
|
-
fireEvent.change(input, { target: { value: 'World' } });
|
|
279
|
-
fireEvent.keyDown(input, { key: 'Enter' });
|
|
280
|
-
expect(onConfirm).toHaveBeenCalledWith('World');
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
it('cancels on Escape key', () => {
|
|
284
|
-
const onCancel = vi.fn();
|
|
285
|
-
render(<FormulaBar value="Hello" onCancel={onCancel} />);
|
|
286
|
-
const input = screen.getByRole('textbox');
|
|
287
|
-
fireEvent.click(input);
|
|
288
|
-
fireEvent.keyDown(input, { key: 'Escape' });
|
|
289
|
-
expect(onCancel).toHaveBeenCalled();
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('calls onConfirm with new value', () => {
|
|
293
|
-
const onConfirm = vi.fn();
|
|
294
|
-
render(<FormulaBar value="old" onConfirm={onConfirm} />);
|
|
295
|
-
const input = screen.getByRole('textbox');
|
|
296
|
-
fireEvent.click(input);
|
|
297
|
-
fireEvent.change(input, { target: { value: 'new' } });
|
|
298
|
-
fireEvent.keyDown(input, { key: 'Enter' });
|
|
299
|
-
expect(onConfirm).toHaveBeenCalledWith('new');
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
it('calls onCancel', () => {
|
|
303
|
-
const onCancel = vi.fn();
|
|
304
|
-
render(<FormulaBar value="val" onCancel={onCancel} />);
|
|
305
|
-
const input = screen.getByRole('textbox');
|
|
306
|
-
fireEvent.click(input);
|
|
307
|
-
fireEvent.keyDown(input, { key: 'Escape' });
|
|
308
|
-
expect(onCancel).toHaveBeenCalledTimes(1);
|
|
309
|
-
});
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
// =========================================================================
|
|
313
|
-
// SplitPaneGrid
|
|
314
|
-
// =========================================================================
|
|
315
|
-
describe('SplitPaneGrid', () => {
|
|
316
|
-
it('renders left and right panes', () => {
|
|
317
|
-
render(
|
|
318
|
-
<SplitPaneGrid
|
|
319
|
-
frozenWidth={200}
|
|
320
|
-
left={<div data-testid="left">Left</div>}
|
|
321
|
-
right={<div data-testid="right">Right</div>}
|
|
322
|
-
/>,
|
|
323
|
-
);
|
|
324
|
-
expect(screen.getByTestId('left')).toBeInTheDocument();
|
|
325
|
-
expect(screen.getByTestId('right')).toBeInTheDocument();
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it('shows resize divider', () => {
|
|
329
|
-
render(
|
|
330
|
-
<SplitPaneGrid
|
|
331
|
-
frozenWidth={200}
|
|
332
|
-
left={<div>L</div>}
|
|
333
|
-
right={<div>R</div>}
|
|
334
|
-
/>,
|
|
335
|
-
);
|
|
336
|
-
expect(screen.getByRole('separator')).toBeInTheDocument();
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it('applies minWidth constraints via frozen pane style', () => {
|
|
340
|
-
const { container } = render(
|
|
341
|
-
<SplitPaneGrid
|
|
342
|
-
frozenWidth={150}
|
|
343
|
-
minFrozenWidth={100}
|
|
344
|
-
minScrollableWidth={200}
|
|
345
|
-
left={<div>L</div>}
|
|
346
|
-
right={<div>R</div>}
|
|
347
|
-
/>,
|
|
348
|
-
);
|
|
349
|
-
// The frozen pane should have the specified width
|
|
350
|
-
const frozenPane = container.querySelector('[style]');
|
|
351
|
-
expect(frozenPane).toHaveStyle({ width: '150px' });
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
// =========================================================================
|
|
356
|
-
// useGroupedData – aggregations
|
|
357
|
-
// =========================================================================
|
|
358
|
-
describe('useGroupedData aggregations', () => {
|
|
359
|
-
const data = [
|
|
360
|
-
{ category: 'A', amount: 10 },
|
|
361
|
-
{ category: 'A', amount: 20 },
|
|
362
|
-
{ category: 'B', amount: 30 },
|
|
363
|
-
{ category: 'B', amount: 40 },
|
|
364
|
-
{ category: 'B', amount: 50 },
|
|
365
|
-
];
|
|
366
|
-
|
|
367
|
-
const config = {
|
|
368
|
-
fields: [{ field: 'category', order: 'asc' as const, collapsed: false }],
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
it('computes sum aggregation per group', () => {
|
|
372
|
-
const agg: AggregationConfig[] = [{ field: 'amount', type: 'sum' }];
|
|
373
|
-
const { result } = renderHook(() => useGroupedData(config, data, agg));
|
|
374
|
-
|
|
375
|
-
const groupA = result.current.groups.find((g) => g.key === 'A')!;
|
|
376
|
-
const groupB = result.current.groups.find((g) => g.key === 'B')!;
|
|
377
|
-
|
|
378
|
-
expect(groupA.aggregations[0]).toEqual({ field: 'amount', type: 'sum', value: 30 });
|
|
379
|
-
expect(groupB.aggregations[0]).toEqual({ field: 'amount', type: 'sum', value: 120 });
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
it('computes count aggregation per group', () => {
|
|
383
|
-
const agg: AggregationConfig[] = [{ field: 'amount', type: 'count' }];
|
|
384
|
-
const { result } = renderHook(() => useGroupedData(config, data, agg));
|
|
385
|
-
|
|
386
|
-
const groupA = result.current.groups.find((g) => g.key === 'A')!;
|
|
387
|
-
const groupB = result.current.groups.find((g) => g.key === 'B')!;
|
|
388
|
-
|
|
389
|
-
expect(groupA.aggregations[0]).toEqual({ field: 'amount', type: 'count', value: 2 });
|
|
390
|
-
expect(groupB.aggregations[0]).toEqual({ field: 'amount', type: 'count', value: 3 });
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
it('computes avg aggregation per group', () => {
|
|
394
|
-
const agg: AggregationConfig[] = [{ field: 'amount', type: 'avg' }];
|
|
395
|
-
const { result } = renderHook(() => useGroupedData(config, data, agg));
|
|
396
|
-
|
|
397
|
-
const groupA = result.current.groups.find((g) => g.key === 'A')!;
|
|
398
|
-
const groupB = result.current.groups.find((g) => g.key === 'B')!;
|
|
399
|
-
|
|
400
|
-
expect(groupA.aggregations[0]).toEqual({ field: 'amount', type: 'avg', value: 15 });
|
|
401
|
-
expect(groupB.aggregations[0]).toEqual({ field: 'amount', type: 'avg', value: 40 });
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
it('handles multiple aggregation types simultaneously', () => {
|
|
405
|
-
const agg: AggregationConfig[] = [
|
|
406
|
-
{ field: 'amount', type: 'sum' },
|
|
407
|
-
{ field: 'amount', type: 'count' },
|
|
408
|
-
{ field: 'amount', type: 'avg' },
|
|
409
|
-
];
|
|
410
|
-
const { result } = renderHook(() => useGroupedData(config, data, agg));
|
|
411
|
-
|
|
412
|
-
const groupA = result.current.groups.find((g) => g.key === 'A')!;
|
|
413
|
-
expect(groupA.aggregations).toHaveLength(3);
|
|
414
|
-
expect(groupA.aggregations[0]).toEqual({ field: 'amount', type: 'sum', value: 30 });
|
|
415
|
-
expect(groupA.aggregations[1]).toEqual({ field: 'amount', type: 'count', value: 2 });
|
|
416
|
-
expect(groupA.aggregations[2]).toEqual({ field: 'amount', type: 'avg', value: 15 });
|
|
417
|
-
});
|
|
418
|
-
});
|