@object-ui/plugin-grid 3.0.3 → 3.1.0

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.
Files changed (44) hide show
  1. package/.turbo/turbo-build.log +12 -6
  2. package/dist/index.js +2169 -922
  3. package/dist/index.umd.cjs +9 -3
  4. package/dist/plugin-grid/src/FormulaBar.d.ts +29 -0
  5. package/dist/plugin-grid/src/GroupRow.d.ts +23 -0
  6. package/dist/plugin-grid/src/ImportWizard.d.ts +29 -0
  7. package/dist/plugin-grid/src/ObjectGrid.d.ts +1 -0
  8. package/dist/plugin-grid/src/SplitPaneGrid.d.ts +22 -0
  9. package/dist/plugin-grid/src/components/BulkActionBar.d.ts +12 -0
  10. package/dist/plugin-grid/src/components/RowActionMenu.d.ts +23 -0
  11. package/dist/plugin-grid/src/index.d.ts +22 -2
  12. package/dist/plugin-grid/src/useCellClipboard.d.ts +47 -0
  13. package/dist/plugin-grid/src/useColumnSummary.d.ts +25 -0
  14. package/dist/plugin-grid/src/useGradientColor.d.ts +37 -0
  15. package/dist/plugin-grid/src/useGroupReorder.d.ts +34 -0
  16. package/dist/plugin-grid/src/useGroupedData.d.ts +24 -3
  17. package/package.json +10 -10
  18. package/src/FormulaBar.tsx +151 -0
  19. package/src/GroupRow.tsx +69 -0
  20. package/src/ImportWizard.tsx +412 -0
  21. package/src/ListColumnExtensions.test.tsx +4 -5
  22. package/src/ObjectGrid.tsx +994 -139
  23. package/src/SplitPaneGrid.tsx +120 -0
  24. package/src/VirtualGrid.tsx +2 -2
  25. package/src/__tests__/GroupRow.test.tsx +206 -0
  26. package/src/__tests__/ImportPreview.test.tsx +171 -0
  27. package/src/__tests__/accessorKey-inference.test.tsx +132 -0
  28. package/src/__tests__/airtable-style.test.tsx +508 -0
  29. package/src/__tests__/column-features.test.tsx +490 -0
  30. package/src/__tests__/grid-export.test.tsx +121 -0
  31. package/src/__tests__/mobile-card-view.test.tsx +355 -0
  32. package/src/__tests__/objectdef-enrichment.test.tsx +566 -0
  33. package/src/__tests__/phase11-features.test.tsx +418 -0
  34. package/src/__tests__/row-bulk-actions.test.tsx +413 -0
  35. package/src/__tests__/row-height.test.tsx +160 -0
  36. package/src/__tests__/useGroupedData.test.ts +165 -0
  37. package/src/components/BulkActionBar.tsx +66 -0
  38. package/src/components/RowActionMenu.tsx +91 -0
  39. package/src/index.tsx +46 -2
  40. package/src/useCellClipboard.ts +136 -0
  41. package/src/useColumnSummary.ts +128 -0
  42. package/src/useGradientColor.ts +103 -0
  43. package/src/useGroupReorder.ts +123 -0
  44. package/src/useGroupedData.ts +69 -4
@@ -0,0 +1,418 @@
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
+ });