@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,508 @@
1
+ /**
2
+ * Airtable-Style Grid Optimizations Tests
3
+ *
4
+ * Tests for auto-type inference (date, select, boolean, user),
5
+ * row number column, compact density, and frozen first column defaults.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import { render, screen, waitFor } from '@testing-library/react';
9
+ import '@testing-library/jest-dom';
10
+ import React from 'react';
11
+ import { ObjectGrid } from '../ObjectGrid';
12
+ import { registerAllFields } from '@object-ui/fields';
13
+ import { ActionProvider } from '@object-ui/react';
14
+ import type { ListColumn } from '@object-ui/types';
15
+
16
+ registerAllFields();
17
+
18
+ // --- Mock Data with various field types ---
19
+ const mockData = [
20
+ {
21
+ _id: '1',
22
+ subject: 'Task Alpha',
23
+ status: 'In Progress',
24
+ priority: 'High',
25
+ category: 'Engineering',
26
+ due_date: '2026-02-20T03:46:37.982Z',
27
+ created_at: '2026-01-15T10:00:00.000Z',
28
+ is_completed: true,
29
+ assignee: 'Alice Smith',
30
+ },
31
+ {
32
+ _id: '2',
33
+ subject: 'Task Beta',
34
+ status: 'Done',
35
+ priority: 'Low',
36
+ category: 'Design',
37
+ due_date: '2026-03-01T00:00:00.000Z',
38
+ created_at: '2026-01-20T10:00:00.000Z',
39
+ is_completed: false,
40
+ assignee: 'Bob Jones',
41
+ },
42
+ {
43
+ _id: '3',
44
+ subject: 'Task Gamma',
45
+ status: 'To Do',
46
+ priority: 'Medium',
47
+ category: 'Engineering',
48
+ due_date: '2026-04-15T00:00:00.000Z',
49
+ created_at: '2026-02-01T10:00:00.000Z',
50
+ is_completed: false,
51
+ assignee: 'Charlie Brown',
52
+ },
53
+ ];
54
+
55
+ function renderGrid(columns: ListColumn[], opts?: Record<string, any>) {
56
+ const schema: any = {
57
+ type: 'object-grid' as const,
58
+ objectName: 'test_object',
59
+ columns,
60
+ data: { provider: 'value', items: mockData },
61
+ ...opts,
62
+ };
63
+
64
+ return render(
65
+ <ActionProvider>
66
+ <ObjectGrid schema={schema} />
67
+ </ActionProvider>
68
+ );
69
+ }
70
+
71
+ // =========================================================================
72
+ // 1. Auto-type inference: Date fields
73
+ // =========================================================================
74
+ describe('Auto-type inference: Date fields', () => {
75
+ it('should format date fields containing "date" in name as human-readable dates', async () => {
76
+ renderGrid([
77
+ { field: 'subject', label: 'Subject' },
78
+ { field: 'due_date', label: 'Due Date' },
79
+ ]);
80
+
81
+ await waitFor(() => {
82
+ expect(screen.getByText('Subject')).toBeInTheDocument();
83
+ });
84
+
85
+ // Should NOT show raw ISO format
86
+ expect(screen.queryByText('2026-02-20T03:46:37.982Z')).not.toBeInTheDocument();
87
+ // Should show human-readable format (relative or absolute depending on distance from today)
88
+ // Date values are rendered in relative format by default
89
+ const cells = screen.getAllByRole('cell');
90
+ const dateCell = cells.find(cell => cell.querySelector('span.tabular-nums'));
91
+ expect(dateCell).toBeInTheDocument();
92
+ });
93
+
94
+ it('should format created_at fields as datetime', async () => {
95
+ renderGrid([
96
+ { field: 'subject', label: 'Subject' },
97
+ { field: 'created_at', label: 'Created At' },
98
+ ]);
99
+
100
+ await waitFor(() => {
101
+ expect(screen.getByText('Subject')).toBeInTheDocument();
102
+ });
103
+
104
+ // Should show human-readable datetime, not ISO string
105
+ expect(screen.queryByText('2026-01-15T10:00:00.000Z')).not.toBeInTheDocument();
106
+ // created_at now renders as datetime with split date/time display
107
+ expect(screen.getByText('1/15/2026')).toBeInTheDocument();
108
+ });
109
+
110
+ it('should not override explicit type for date-like fields', async () => {
111
+ renderGrid([
112
+ { field: 'subject', label: 'Subject' },
113
+ { field: 'due_date', label: 'Due Date', type: 'text' },
114
+ ]);
115
+
116
+ await waitFor(() => {
117
+ expect(screen.getByText('Subject')).toBeInTheDocument();
118
+ });
119
+
120
+ // With explicit type: 'text', should render as text
121
+ expect(screen.getByText('2026-02-20T03:46:37.982Z')).toBeInTheDocument();
122
+ });
123
+ });
124
+
125
+ // =========================================================================
126
+ // 2. Auto-type inference: Select/Badge fields
127
+ // =========================================================================
128
+ describe('Auto-type inference: Select/Badge fields', () => {
129
+ it('should render status field as badge when values are enumerable', async () => {
130
+ renderGrid([
131
+ { field: 'subject', label: 'Subject' },
132
+ { field: 'status', label: 'Status' },
133
+ ]);
134
+
135
+ await waitFor(() => {
136
+ expect(screen.getByText('Subject')).toBeInTheDocument();
137
+ });
138
+
139
+ // Status values should be present
140
+ expect(screen.getByText('In Progress')).toBeInTheDocument();
141
+ expect(screen.getByText('Done')).toBeInTheDocument();
142
+ expect(screen.getByText('To Do')).toBeInTheDocument();
143
+ });
144
+
145
+ it('should render priority field as badge', async () => {
146
+ renderGrid([
147
+ { field: 'subject', label: 'Subject' },
148
+ { field: 'priority', label: 'Priority' },
149
+ ]);
150
+
151
+ await waitFor(() => {
152
+ expect(screen.getByText('Subject')).toBeInTheDocument();
153
+ });
154
+
155
+ expect(screen.getByText('High')).toBeInTheDocument();
156
+ expect(screen.getByText('Low')).toBeInTheDocument();
157
+ expect(screen.getByText('Medium')).toBeInTheDocument();
158
+ });
159
+ });
160
+
161
+ // =========================================================================
162
+ // 3. Auto-type inference: Boolean/Checkbox fields
163
+ // =========================================================================
164
+ describe('Auto-type inference: Boolean/Checkbox fields', () => {
165
+ it('should render is_completed as semantic completion indicator', async () => {
166
+ renderGrid([
167
+ { field: 'subject', label: 'Subject' },
168
+ { field: 'is_completed', label: 'Is Completed' },
169
+ ]);
170
+
171
+ await waitFor(() => {
172
+ expect(screen.getByText('Subject')).toBeInTheDocument();
173
+ });
174
+
175
+ // Should render green circle completion indicators (not checkboxes)
176
+ const indicators = screen.getAllByTestId('completion-indicator');
177
+ expect(indicators.length).toBeGreaterThan(0);
178
+ });
179
+ });
180
+
181
+ // =========================================================================
182
+ // 4. Row number column
183
+ // =========================================================================
184
+ describe('Row number column', () => {
185
+ it('should show row numbers by default in ObjectGrid', async () => {
186
+ renderGrid([
187
+ { field: 'subject', label: 'Subject' },
188
+ { field: 'status', label: 'Status' },
189
+ ]);
190
+
191
+ await waitFor(() => {
192
+ expect(screen.getByText('Subject')).toBeInTheDocument();
193
+ });
194
+
195
+ // Row numbers should be rendered (1, 2, 3)
196
+ expect(screen.getByText('1')).toBeInTheDocument();
197
+ expect(screen.getByText('2')).toBeInTheDocument();
198
+ expect(screen.getByText('3')).toBeInTheDocument();
199
+ });
200
+ });
201
+
202
+ // =========================================================================
203
+ // 5. Compact row density (default medium)
204
+ // =========================================================================
205
+ describe('Row density', () => {
206
+ it('should apply default medium density classes', async () => {
207
+ renderGrid([
208
+ { field: 'subject', label: 'Subject' },
209
+ ]);
210
+
211
+ await waitFor(() => {
212
+ expect(screen.getByText('Subject')).toBeInTheDocument();
213
+ });
214
+
215
+ // Verify data-table is rendered (implicitly has the cellClassName applied)
216
+ expect(screen.getByText('Task Alpha')).toBeInTheDocument();
217
+ });
218
+ });
219
+
220
+ // =========================================================================
221
+ // 6. Frozen first column default
222
+ // =========================================================================
223
+ describe('Frozen first column', () => {
224
+ it('should default frozenColumns to 1', async () => {
225
+ renderGrid([
226
+ { field: 'subject', label: 'Subject' },
227
+ { field: 'status', label: 'Status' },
228
+ ]);
229
+
230
+ await waitFor(() => {
231
+ expect(screen.getByText('Subject')).toBeInTheDocument();
232
+ });
233
+
234
+ // The first column header should have sticky class (indicating it's frozen)
235
+ const subjectHeader = screen.getByText('Subject').closest('th');
236
+ expect(subjectHeader).toHaveClass('sticky');
237
+ });
238
+
239
+ it('should not freeze columns when frozenColumns is explicitly 0', async () => {
240
+ renderGrid(
241
+ [
242
+ { field: 'subject', label: 'Subject' },
243
+ { field: 'status', label: 'Status' },
244
+ ],
245
+ { frozenColumns: 0 }
246
+ );
247
+
248
+ await waitFor(() => {
249
+ expect(screen.getByText('Subject')).toBeInTheDocument();
250
+ });
251
+
252
+ const subjectHeader = screen.getByText('Subject').closest('th');
253
+ expect(subjectHeader).not.toHaveClass('sticky');
254
+ });
255
+ });
256
+
257
+ // =========================================================================
258
+ // 7. Column header type icons
259
+ // =========================================================================
260
+ describe('Column header type icons', () => {
261
+ it('should render type icons in column headers', async () => {
262
+ renderGrid([
263
+ { field: 'subject', label: 'Subject' },
264
+ { field: 'status', label: 'Status' },
265
+ { field: 'due_date', label: 'Due Date' },
266
+ ]);
267
+ await waitFor(() => {
268
+ expect(screen.getByText('Subject')).toBeInTheDocument();
269
+ });
270
+ // Each column header should have an SVG icon
271
+ const headers = screen.getAllByRole('columnheader');
272
+ // Filter out utility headers (row numbers, checkboxes, actions)
273
+ const dataHeaders = headers.filter(h => h.querySelector('svg'));
274
+ expect(dataHeaders.length).toBeGreaterThanOrEqual(3);
275
+ });
276
+ });
277
+
278
+ // =========================================================================
279
+ // 8. Datetime type inference
280
+ // =========================================================================
281
+ describe('Datetime type inference', () => {
282
+ it('should infer datetime type for created_at fields', async () => {
283
+ renderGrid([
284
+ { field: 'subject', label: 'Subject' },
285
+ { field: 'created_at', label: 'Created At' },
286
+ ]);
287
+ await waitFor(() => {
288
+ expect(screen.getByText('Subject')).toBeInTheDocument();
289
+ });
290
+ // Should show split datetime format (date and time separately)
291
+ // The time part should be rendered in a separate muted span
292
+ const dateEl = screen.getByText('1/15/2026');
293
+ expect(dateEl).toBeInTheDocument();
294
+ });
295
+ });
296
+
297
+ // =========================================================================
298
+ // 9. Compound cell with prefix badge
299
+ // =========================================================================
300
+ describe('Compound cell with prefix badge', () => {
301
+ it('should render prefix badge before the main value', async () => {
302
+ renderGrid([
303
+ { field: 'subject', label: 'Subject', prefix: { field: 'category', type: 'badge' } },
304
+ { field: 'status', label: 'Status' },
305
+ ]);
306
+ await waitFor(() => {
307
+ expect(screen.getByText('Subject')).toBeInTheDocument();
308
+ });
309
+ // The category values should appear as badges alongside subject
310
+ const engineeringBadges = screen.getAllByText('Engineering');
311
+ expect(engineeringBadges.length).toBeGreaterThanOrEqual(1);
312
+ expect(screen.getByText('Task Alpha')).toBeInTheDocument();
313
+ });
314
+ });
315
+
316
+ // =========================================================================
317
+ // 10. Add record row
318
+ // =========================================================================
319
+ describe('Add record row', () => {
320
+ it('should show add record row when operations.create is true', async () => {
321
+ renderGrid(
322
+ [
323
+ { field: 'subject', label: 'Subject' },
324
+ { field: 'status', label: 'Status' },
325
+ ],
326
+ { operations: { create: true } }
327
+ );
328
+ await waitFor(() => {
329
+ expect(screen.getByText('Subject')).toBeInTheDocument();
330
+ });
331
+ expect(screen.getByTestId('add-record-row')).toBeInTheDocument();
332
+ expect(screen.getByText('Add record')).toBeInTheDocument();
333
+ });
334
+
335
+ it('should not show add record row by default', async () => {
336
+ renderGrid([
337
+ { field: 'subject', label: 'Subject' },
338
+ { field: 'status', label: 'Status' },
339
+ ]);
340
+ await waitFor(() => {
341
+ expect(screen.getByText('Subject')).toBeInTheDocument();
342
+ });
343
+ expect(screen.queryByTestId('add-record-row')).not.toBeInTheDocument();
344
+ });
345
+ });
346
+
347
+ // =========================================================================
348
+ // 11. Primary field auto-link
349
+ // =========================================================================
350
+ describe('Primary field auto-link', () => {
351
+ it('should render first column cells as clickable links (primary field)', async () => {
352
+ renderGrid([
353
+ { field: 'subject', label: 'Subject' },
354
+ { field: 'status', label: 'Status' },
355
+ ]);
356
+ await waitFor(() => {
357
+ expect(screen.getByText('Subject')).toBeInTheDocument();
358
+ });
359
+
360
+ // First column cells should be rendered as buttons (links) with primary-field-link testid
361
+ const primaryLinks = screen.getAllByTestId('primary-field-link');
362
+ expect(primaryLinks.length).toBe(3); // 3 data rows
363
+ expect(primaryLinks[0]).toHaveTextContent('Task Alpha');
364
+ expect(primaryLinks[1]).toHaveTextContent('Task Beta');
365
+ expect(primaryLinks[2]).toHaveTextContent('Task Gamma');
366
+ });
367
+
368
+ it('should style primary field cells with font-medium and text-primary', async () => {
369
+ renderGrid([
370
+ { field: 'subject', label: 'Subject' },
371
+ { field: 'status', label: 'Status' },
372
+ ]);
373
+ await waitFor(() => {
374
+ expect(screen.getByText('Subject')).toBeInTheDocument();
375
+ });
376
+
377
+ const primaryLinks = screen.getAllByTestId('primary-field-link');
378
+ // Primary field links should have text-primary and font-medium classes
379
+ expect(primaryLinks[0]).toHaveClass('text-primary');
380
+ expect(primaryLinks[0]).toHaveClass('font-medium');
381
+ });
382
+
383
+ it('should not auto-link first column when it already has explicit link=true', async () => {
384
+ renderGrid([
385
+ { field: 'subject', label: 'Subject', link: true },
386
+ { field: 'status', label: 'Status' },
387
+ ]);
388
+ await waitFor(() => {
389
+ expect(screen.getByText('Subject')).toBeInTheDocument();
390
+ });
391
+
392
+ // Still should have clickable buttons (via explicit link), but no primary-field-link testid
393
+ // because isPrimaryField is only true when !col.link && !col.action
394
+ const primaryLinks = screen.queryAllByTestId('primary-field-link');
395
+ expect(primaryLinks.length).toBe(0);
396
+
397
+ // But the cells should still be buttons (from col.link path)
398
+ const subjectCell = screen.getByText('Task Alpha');
399
+ expect(subjectCell.closest('button')).toBeTruthy();
400
+ });
401
+ });
402
+
403
+ // =========================================================================
404
+ // 12. Empty value display
405
+ // =========================================================================
406
+ describe('Empty value display', () => {
407
+ it('should show styled empty indicator for null/empty values', async () => {
408
+ const dataWithEmpty = [
409
+ { _id: '1', subject: 'Task Alpha', company: null },
410
+ { _id: '2', subject: 'Task Beta', company: '' },
411
+ { _id: '3', subject: 'Task Gamma', company: 'Acme' },
412
+ ];
413
+
414
+ const schema: any = {
415
+ type: 'object-grid' as const,
416
+ objectName: 'test_object',
417
+ columns: [
418
+ { field: 'subject', label: 'Subject' },
419
+ { field: 'company', label: 'Company' },
420
+ ],
421
+ data: { provider: 'value', items: dataWithEmpty },
422
+ };
423
+
424
+ render(
425
+ <ActionProvider>
426
+ <ObjectGrid schema={schema} />
427
+ </ActionProvider>
428
+ );
429
+
430
+ await waitFor(() => {
431
+ expect(screen.getByText('Subject')).toBeInTheDocument();
432
+ });
433
+
434
+ // Non-empty value should be displayed normally
435
+ expect(screen.getByText('Acme')).toBeInTheDocument();
436
+
437
+ // Empty values should show muted dash indicators
438
+ const emptyIndicators = screen.getAllByText('—');
439
+ expect(emptyIndicators.length).toBeGreaterThanOrEqual(2);
440
+ // Verify they have the styled classes
441
+ emptyIndicators.forEach(el => {
442
+ expect(el.className).toContain('italic');
443
+ });
444
+ });
445
+ });
446
+
447
+ // =========================================================================
448
+ // 13. Record detail panel (form-based)
449
+ // =========================================================================
450
+ describe('Record detail panel', () => {
451
+ it('should render form-based record detail with renderRecordDetail', async () => {
452
+ const onRowClick = vi.fn();
453
+ renderGrid(
454
+ [
455
+ { field: 'subject', label: 'Subject' },
456
+ { field: 'status', label: 'Status' },
457
+ ],
458
+ {
459
+ navigation: { mode: 'drawer' },
460
+ }
461
+ );
462
+
463
+ await waitFor(() => {
464
+ expect(screen.getByText('Subject')).toBeInTheDocument();
465
+ });
466
+
467
+ // The grid should render successfully with navigation configured
468
+ expect(screen.getByText('Task Alpha')).toBeInTheDocument();
469
+ });
470
+ });
471
+
472
+ // =========================================================================
473
+ // 13. Auto-type inference: Currency/Amount fields
474
+ // =========================================================================
475
+ describe('Auto-type inference: Currency/Amount fields', () => {
476
+ const currencyData = [
477
+ { _id: '1', name: 'Order 1', total_amount: 15459.99 },
478
+ { _id: '2', name: 'Order 2', total_amount: 289.50 },
479
+ ];
480
+
481
+ function renderCurrencyGrid(columns: ListColumn[]) {
482
+ const schema: any = {
483
+ type: 'object-grid' as const,
484
+ objectName: 'test_object',
485
+ columns,
486
+ data: { provider: 'value', items: currencyData },
487
+ };
488
+
489
+ return render(
490
+ <ActionProvider>
491
+ <ObjectGrid schema={schema} />
492
+ </ActionProvider>
493
+ );
494
+ }
495
+
496
+ it('should auto-infer currency type for amount fields and format values', async () => {
497
+ renderCurrencyGrid([
498
+ { field: 'name', label: 'Name' },
499
+ { field: 'total_amount', label: 'Amount' },
500
+ ]);
501
+ await waitFor(() => {
502
+ expect(screen.getByText('Name')).toBeInTheDocument();
503
+ });
504
+ // Should show formatted currency (e.g. "$15,459.99") instead of raw "15459.99"
505
+ expect(screen.queryByText('15459.99')).not.toBeInTheDocument();
506
+ expect(screen.getByText(/15,459\.99/)).toBeInTheDocument();
507
+ });
508
+ });