@object-ui/fields 3.4.0 → 4.0.3

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 (26) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/package.json +7 -7
  3. package/dist/packages/fields/src/__tests__/boolean-checkbox.test.d.ts +0 -0
  4. package/dist/packages/fields/src/__tests__/cell-renderers.test.d.ts +0 -0
  5. package/dist/packages/fields/src/__tests__/datetime-cell.test.d.ts +0 -0
  6. package/dist/packages/fields/src/__tests__/datetime-timezone.test.d.ts +0 -0
  7. package/dist/packages/fields/src/__tests__/extreme-inputs.test.d.ts +0 -0
  8. package/dist/packages/fields/src/__tests__/form-variants.test.d.ts +0 -0
  9. package/dist/packages/fields/src/lookup-depends-on.test.d.ts +0 -1
  10. package/dist/packages/fields/src/new-widgets.test.d.ts +0 -8
  11. package/dist/packages/fields/src/record-picker.test.d.ts +0 -1
  12. package/dist/packages/fields/src/remaining-widgets.test.d.ts +0 -1
  13. package/dist/packages/fields/src/text-rich-widgets.test.d.ts +0 -1
  14. package/dist/packages/fields/src/visual-widgets.test.d.ts +0 -1
  15. package/src/__tests__/boolean-checkbox.test.tsx +0 -74
  16. package/src/__tests__/cell-renderers.test.tsx +0 -1193
  17. package/src/__tests__/datetime-cell.test.tsx +0 -46
  18. package/src/__tests__/datetime-timezone.test.tsx +0 -770
  19. package/src/__tests__/extreme-inputs.test.tsx +0 -567
  20. package/src/__tests__/form-variants.test.tsx +0 -306
  21. package/src/lookup-depends-on.test.tsx +0 -130
  22. package/src/new-widgets.test.tsx +0 -302
  23. package/src/record-picker.test.tsx +0 -1703
  24. package/src/remaining-widgets.test.tsx +0 -211
  25. package/src/text-rich-widgets.test.tsx +0 -104
  26. package/src/visual-widgets.test.tsx +0 -97
@@ -1,1193 +0,0 @@
1
- /**
2
- * Cell Renderer Tests
3
- *
4
- * Tests for getCellRenderer() select/date/boolean type renderers
5
- * and formatRelativeDate() edge cases.
6
- */
7
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
- import { render, screen } from '@testing-library/react';
9
- import '@testing-library/jest-dom';
10
- import React from 'react';
11
- import {
12
- getCellRenderer,
13
- SelectCellRenderer,
14
- LookupCellRenderer,
15
- UserCellRenderer,
16
- TextCellRenderer,
17
- DateCellRenderer,
18
- BooleanCellRenderer,
19
- EmailCellRenderer,
20
- PhoneCellRenderer,
21
- PercentCellRenderer,
22
- humanizeLabel,
23
- formatDate,
24
- formatRelativeDate,
25
- } from '../index';
26
-
27
- // =========================================================================
28
- // 1. getCellRenderer type resolution
29
- // =========================================================================
30
- describe('getCellRenderer', () => {
31
- it('should return SelectCellRenderer for select type', () => {
32
- const renderer = getCellRenderer('select');
33
- expect(renderer).toBeDefined();
34
- // Verify it renders a badge
35
- const { container } = render(
36
- React.createElement(renderer, {
37
- value: 'Active',
38
- field: { name: 'status', type: 'select', options: [{ value: 'Active', label: 'Active', color: 'green' }] } as any,
39
- })
40
- );
41
- expect(container.querySelector('[class*="bg-green"]')).toBeInTheDocument();
42
- });
43
-
44
- it('should return SelectCellRenderer for status type', () => {
45
- const renderer = getCellRenderer('status');
46
- expect(renderer).toBeDefined();
47
- // Verify it renders a badge (same as select)
48
- const { container } = render(
49
- React.createElement(renderer, {
50
- value: 'Active',
51
- field: { name: 'status', type: 'status', options: [{ value: 'Active', label: 'Active', color: 'green' }] } as any,
52
- })
53
- );
54
- expect(container.querySelector('[class*="bg-green"]')).toBeInTheDocument();
55
- });
56
-
57
- it('should return DateCellRenderer for date type', () => {
58
- const renderer = getCellRenderer('date');
59
- expect(renderer).toBeDefined();
60
- });
61
-
62
- it('should return BooleanCellRenderer for boolean type', () => {
63
- const renderer = getCellRenderer('boolean');
64
- expect(renderer).toBeDefined();
65
- });
66
-
67
- it('should fallback to TextCellRenderer for unknown types', () => {
68
- const renderer = getCellRenderer('unknown-type');
69
- expect(renderer).toBeDefined();
70
- });
71
-
72
- it('should return LookupCellRenderer for lookup type', () => {
73
- const renderer = getCellRenderer('lookup');
74
- expect(renderer).toBe(LookupCellRenderer);
75
- });
76
-
77
- it('should return LookupCellRenderer for master_detail type', () => {
78
- const renderer = getCellRenderer('master_detail');
79
- expect(renderer).toBe(LookupCellRenderer);
80
- });
81
-
82
- it('should return SelectCellRenderer for status type', () => {
83
- const renderer = getCellRenderer('status');
84
- expect(renderer).toBe(SelectCellRenderer);
85
- });
86
-
87
- it('should return UserCellRenderer for user type', () => {
88
- const renderer = getCellRenderer('user');
89
- expect(renderer).toBe(UserCellRenderer);
90
- });
91
-
92
- it('should return UserCellRenderer for owner type', () => {
93
- const renderer = getCellRenderer('owner');
94
- expect(renderer).toBe(UserCellRenderer);
95
- });
96
- });
97
-
98
- // =========================================================================
99
- // 2. TextCellRenderer
100
- // =========================================================================
101
- describe('TextCellRenderer', () => {
102
- it('should render text value', () => {
103
- render(<TextCellRenderer value="hello" field={{ name: 'title', type: 'text' } as any} />);
104
- expect(screen.getByText('hello')).toBeInTheDocument();
105
- });
106
-
107
- it('should render dash for null', () => {
108
- render(<TextCellRenderer value={null} field={{ name: 'title', type: 'text' } as any} />);
109
- expect(screen.getByText('—')).toBeInTheDocument();
110
- });
111
-
112
- it('should render dash for undefined', () => {
113
- render(<TextCellRenderer value={undefined} field={{ name: 'title', type: 'text' } as any} />);
114
- expect(screen.getByText('—')).toBeInTheDocument();
115
- });
116
-
117
- it('should render dash for empty string', () => {
118
- render(<TextCellRenderer value="" field={{ name: 'title', type: 'text' } as any} />);
119
- expect(screen.getByText('—')).toBeInTheDocument();
120
- });
121
-
122
- it('should render "0" for numeric zero (not dash)', () => {
123
- render(<TextCellRenderer value={0} field={{ name: 'count', type: 'text' } as any} />);
124
- expect(screen.getByText('0')).toBeInTheDocument();
125
- });
126
-
127
- it('should render "false" for boolean false (not dash)', () => {
128
- render(<TextCellRenderer value={false} field={{ name: 'flag', type: 'text' } as any} />);
129
- expect(screen.getByText('false')).toBeInTheDocument();
130
- });
131
- });
132
-
133
- // =========================================================================
134
- // 3. SelectCellRenderer
135
- // =========================================================================
136
- describe('SelectCellRenderer', () => {
137
- it('should render badge with explicit color from options', () => {
138
- const { container } = render(
139
- <SelectCellRenderer
140
- value="In Progress"
141
- field={{
142
- name: 'status',
143
- type: 'select',
144
- options: [{ value: 'In Progress', label: 'In Progress', color: 'blue' }],
145
- } as any}
146
- />
147
- );
148
- expect(screen.getByText('In Progress')).toBeInTheDocument();
149
- expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
150
- });
151
-
152
- it('should fall back to a deterministic palette color when no color configured and no semantic match', () => {
153
- // Hash-based fallback: distinct values get distinct colors so badges
154
- // never all collapse to muted gray. The exact color is deterministic
155
- // (stable across renders) but value-derived; we just assert one of the
156
- // palette tones is applied.
157
- const { container, rerender } = render(
158
- <SelectCellRenderer
159
- value="Unknown"
160
- field={{
161
- name: 'status',
162
- type: 'select',
163
- options: [{ value: 'Unknown', label: 'Unknown' }],
164
- } as any}
165
- />
166
- );
167
- expect(screen.getByText('Unknown')).toBeInTheDocument();
168
- const palette = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
169
- const matched = palette.some((c) => container.querySelector(`[class*="bg-${c}-50"]`));
170
- expect(matched).toBe(true);
171
-
172
- // Same value → same color across renders (deterministic).
173
- const firstClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-50"]')?.className;
174
- rerender(
175
- <SelectCellRenderer
176
- value="Unknown"
177
- field={{
178
- name: 'status',
179
- type: 'select',
180
- options: [{ value: 'Unknown', label: 'Unknown' }],
181
- } as any}
182
- />
183
- );
184
- const secondClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-50"]')?.className;
185
- expect(secondClass).toBe(firstClass);
186
- });
187
-
188
- it('should auto-detect priority semantic colors (Critical → red)', () => {
189
- const { container } = render(
190
- <SelectCellRenderer
191
- value="Critical"
192
- field={{ name: 'priority', type: 'select', options: [] } as any}
193
- />
194
- );
195
- expect(screen.getByText('Critical')).toBeInTheDocument();
196
- expect(container.querySelector('[class*="bg-red-50"]')).toBeInTheDocument();
197
- });
198
-
199
- it('should auto-detect priority semantic colors (High → orange)', () => {
200
- const { container } = render(
201
- <SelectCellRenderer
202
- value="High"
203
- field={{ name: 'priority', type: 'select', options: [] } as any}
204
- />
205
- );
206
- expect(container.querySelector('[class*="bg-orange-50"]')).toBeInTheDocument();
207
- });
208
-
209
- it('should auto-detect priority semantic colors (Medium → yellow)', () => {
210
- const { container } = render(
211
- <SelectCellRenderer
212
- value="Medium"
213
- field={{ name: 'priority', type: 'select', options: [] } as any}
214
- />
215
- );
216
- expect(container.querySelector('[class*="bg-yellow-50"]')).toBeInTheDocument();
217
- });
218
-
219
- it('should auto-detect priority semantic colors (Low → gray)', () => {
220
- const { container } = render(
221
- <SelectCellRenderer
222
- value="Low"
223
- field={{ name: 'priority', type: 'select', options: [] } as any}
224
- />
225
- );
226
- expect(container.querySelector('[class*="bg-gray-50"]')).toBeInTheDocument();
227
- });
228
-
229
- it('should auto-detect status semantic colors (Paid → green)', () => {
230
- const { container } = render(
231
- <SelectCellRenderer
232
- value="Paid"
233
- field={{ name: 'status', type: 'select', options: [] } as any}
234
- />
235
- );
236
- expect(container.querySelector('[class*="bg-green-50"]')).toBeInTheDocument();
237
- });
238
-
239
- it('should auto-detect status semantic colors (Pending → yellow)', () => {
240
- const { container } = render(
241
- <SelectCellRenderer
242
- value="Pending"
243
- field={{ name: 'status', type: 'select', options: [] } as any}
244
- />
245
- );
246
- expect(container.querySelector('[class*="bg-yellow-50"]')).toBeInTheDocument();
247
- });
248
-
249
- it('should auto-detect status semantic colors (Shipped → blue)', () => {
250
- const { container } = render(
251
- <SelectCellRenderer
252
- value="Shipped"
253
- field={{ name: 'status', type: 'select', options: [] } as any}
254
- />
255
- );
256
- expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
257
- });
258
-
259
- it('should auto-detect status semantic colors (Draft → gray)', () => {
260
- const { container } = render(
261
- <SelectCellRenderer
262
- value="Draft"
263
- field={{ name: 'status', type: 'select', options: [] } as any}
264
- />
265
- );
266
- expect(container.querySelector('[class*="bg-gray-50"]')).toBeInTheDocument();
267
- });
268
-
269
- it('should auto-detect status semantic colors (Cancelled → red)', () => {
270
- const { container } = render(
271
- <SelectCellRenderer
272
- value="Cancelled"
273
- field={{ name: 'status', type: 'select', options: [] } as any}
274
- />
275
- );
276
- expect(container.querySelector('[class*="bg-red-50"]')).toBeInTheDocument();
277
- });
278
-
279
- it('should auto-detect status semantic colors (Delivered → purple)', () => {
280
- const { container } = render(
281
- <SelectCellRenderer
282
- value="Delivered"
283
- field={{ name: 'status', type: 'select', options: [] } as any}
284
- />
285
- );
286
- expect(container.querySelector('[class*="bg-purple-50"]')).toBeInTheDocument();
287
- });
288
-
289
- it('should auto-detect status semantic colors with snake_case (in_progress → blue)', () => {
290
- const { container } = render(
291
- <SelectCellRenderer
292
- value="in_progress"
293
- field={{ name: 'status', type: 'select', options: [] } as any}
294
- />
295
- );
296
- expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
297
- });
298
-
299
- it('should auto-detect status semantic colors with hyphen (in-progress → blue)', () => {
300
- const { container } = render(
301
- <SelectCellRenderer
302
- value="in-progress"
303
- field={{ name: 'status', type: 'select', options: [] } as any}
304
- />
305
- );
306
- expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
307
- });
308
-
309
- it('should auto-detect status semantic colors with spaces (in progress → blue)', () => {
310
- const { container } = render(
311
- <SelectCellRenderer
312
- value="in progress"
313
- field={{ name: 'status', type: 'select', options: [] } as any}
314
- />
315
- );
316
- expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
317
- });
318
-
319
- it('should render dash for null/empty value', () => {
320
- render(
321
- <SelectCellRenderer
322
- value={null}
323
- field={{ name: 'status', type: 'select' } as any}
324
- />
325
- );
326
- expect(screen.getByText('—')).toBeInTheDocument();
327
- });
328
-
329
- it('should render multiple badges for array values', () => {
330
- render(
331
- <SelectCellRenderer
332
- value={['Draft', 'Active']}
333
- field={{
334
- name: 'tags',
335
- type: 'select',
336
- options: [
337
- { value: 'Draft', label: 'Draft', color: 'yellow' },
338
- { value: 'Active', label: 'Active', color: 'green' },
339
- ],
340
- } as any}
341
- />
342
- );
343
- expect(screen.getByText('Draft')).toBeInTheDocument();
344
- expect(screen.getByText('Active')).toBeInTheDocument();
345
- });
346
-
347
- it('should prefer explicit option color over auto-detected', () => {
348
- const { container } = render(
349
- <SelectCellRenderer
350
- value="High"
351
- field={{
352
- name: 'priority',
353
- type: 'select',
354
- options: [{ value: 'High', label: 'High', color: 'purple' }],
355
- } as any}
356
- />
357
- );
358
- // Explicit purple should override auto-detected orange
359
- expect(container.querySelector('[class*="bg-purple-50"]')).toBeInTheDocument();
360
- });
361
- });
362
-
363
- // =========================================================================
364
- // 3. DateCellRenderer
365
- // =========================================================================
366
- describe('DateCellRenderer', () => {
367
- beforeEach(() => {
368
- // Fix "now" to a known date: 2026-02-24T12:00:00Z using fake timers
369
- // so that new Date() in formatRelativeDate returns a predictable value
370
- vi.useFakeTimers();
371
- vi.setSystemTime(new Date('2026-02-24T12:00:00Z'));
372
- });
373
-
374
- afterEach(() => {
375
- vi.useRealTimers();
376
- });
377
-
378
- it('should render today date as "Today"', () => {
379
- render(
380
- <DateCellRenderer
381
- value="2026-02-24T08:00:00Z"
382
- field={{ name: 'due_date', type: 'date' } as any}
383
- />
384
- );
385
- expect(screen.getByText('Today')).toBeInTheDocument();
386
- });
387
-
388
- it('should render yesterday date as "Yesterday"', () => {
389
- render(
390
- <DateCellRenderer
391
- value="2026-02-23T08:00:00Z"
392
- field={{ name: 'due_date', type: 'date' } as any}
393
- />
394
- );
395
- expect(screen.getByText('Yesterday')).toBeInTheDocument();
396
- });
397
-
398
- it('should render recent past date as "Overdue Xd"', () => {
399
- render(
400
- <DateCellRenderer
401
- value="2026-02-20T08:00:00Z"
402
- field={{ name: 'due_date', type: 'date' } as any}
403
- />
404
- );
405
- expect(screen.getByText('Overdue 4d')).toBeInTheDocument();
406
- });
407
-
408
- it('should render overdue date with red text styling', () => {
409
- const { container } = render(
410
- <DateCellRenderer
411
- value="2026-02-20T08:00:00Z"
412
- field={{ name: 'due_date', type: 'date' } as any}
413
- />
414
- );
415
- expect(container.querySelector('.text-red-600')).toBeInTheDocument();
416
- });
417
-
418
- it('should show ISO string as tooltip', () => {
419
- const { container } = render(
420
- <DateCellRenderer
421
- value="2026-02-20T08:00:00Z"
422
- field={{ name: 'due_date', type: 'date' } as any}
423
- />
424
- );
425
- const span = container.querySelector('span[title]');
426
- expect(span).toBeInTheDocument();
427
- expect(span?.getAttribute('title')).toContain('2026-02-20');
428
- });
429
-
430
- it('should render dash for null value', () => {
431
- const { container } = render(
432
- <DateCellRenderer
433
- value={null}
434
- field={{ name: 'due_date', type: 'date' } as any}
435
- />
436
- );
437
- expect(container.textContent).toBe('—');
438
- });
439
-
440
- it('should use explicit format when provided', () => {
441
- render(
442
- <DateCellRenderer
443
- value="2026-01-15T08:00:00Z"
444
- field={{ name: 'created', type: 'date', format: 'short' } as any}
445
- />
446
- );
447
- // Short format: "Jan 15, '26"
448
- expect(screen.getByText("Jan 15, '26")).toBeInTheDocument();
449
- });
450
-
451
- it('should not show red styling for future dates', () => {
452
- const { container } = render(
453
- <DateCellRenderer
454
- value="2026-03-01T08:00:00Z"
455
- field={{ name: 'due_date', type: 'date' } as any}
456
- />
457
- );
458
- expect(container.querySelector('.text-red-600')).not.toBeInTheDocument();
459
- });
460
- });
461
-
462
- // =========================================================================
463
- // 4. BooleanCellRenderer
464
- // =========================================================================
465
- describe('BooleanCellRenderer', () => {
466
- it('should render checked checkbox for true', () => {
467
- render(
468
- <BooleanCellRenderer
469
- value={true}
470
- field={{ name: 'active', type: 'boolean' } as any}
471
- />
472
- );
473
- const checkbox = screen.getByRole('checkbox');
474
- expect(checkbox).toHaveAttribute('data-state', 'checked');
475
- });
476
-
477
- it('should render unchecked checkbox for false', () => {
478
- render(
479
- <BooleanCellRenderer
480
- value={false}
481
- field={{ name: 'flagged', type: 'boolean' } as any}
482
- />
483
- );
484
- const checkbox = screen.getByRole('checkbox');
485
- expect(checkbox).toHaveAttribute('data-state', 'unchecked');
486
- });
487
-
488
- it('should render dash for null', () => {
489
- render(
490
- <BooleanCellRenderer
491
- value={null}
492
- field={{ name: 'active', type: 'boolean' } as any}
493
- />
494
- );
495
- expect(screen.getByText('—')).toBeInTheDocument();
496
- });
497
-
498
- it('should render dash for undefined', () => {
499
- render(
500
- <BooleanCellRenderer
501
- value={undefined}
502
- field={{ name: 'active', type: 'boolean' } as any}
503
- />
504
- );
505
- expect(screen.getByText('—')).toBeInTheDocument();
506
- });
507
-
508
- it('should render green circle indicator for is_completed=true', () => {
509
- const { container } = render(
510
- <BooleanCellRenderer
511
- value={true}
512
- field={{ name: 'is_completed', type: 'boolean' } as any}
513
- />
514
- );
515
- const indicator = container.querySelector('[data-testid="completion-indicator"]');
516
- expect(indicator).toBeInTheDocument();
517
- expect(indicator).toHaveClass('bg-green-500');
518
- });
519
-
520
- it('should render empty circle indicator for is_completed=false', () => {
521
- const { container } = render(
522
- <BooleanCellRenderer
523
- value={false}
524
- field={{ name: 'is_completed', type: 'boolean' } as any}
525
- />
526
- );
527
- const indicator = container.querySelector('[data-testid="completion-indicator"]');
528
- expect(indicator).toBeInTheDocument();
529
- expect(indicator).toHaveClass('border-2');
530
- });
531
-
532
- it('should render green circle indicator for completed=true', () => {
533
- const { container } = render(
534
- <BooleanCellRenderer
535
- value={true}
536
- field={{ name: 'completed', type: 'boolean' } as any}
537
- />
538
- );
539
- const indicator = container.querySelector('[data-testid="completion-indicator"]');
540
- expect(indicator).toBeInTheDocument();
541
- expect(indicator).toHaveClass('bg-green-500');
542
- });
543
-
544
- it('should render standard checkbox for non-completion boolean fields', () => {
545
- render(
546
- <BooleanCellRenderer
547
- value={true}
548
- field={{ name: 'active', type: 'boolean' } as any}
549
- />
550
- );
551
- const checkbox = screen.getByRole('checkbox');
552
- expect(checkbox).toHaveAttribute('data-state', 'checked');
553
- });
554
- });
555
-
556
- // =========================================================================
557
- // 4b. BooleanCellRenderer — Warning badge for status fields
558
- // =========================================================================
559
- describe('BooleanCellRenderer warning badge', () => {
560
- it('should render warning badge for active=false', () => {
561
- const { container } = render(
562
- <BooleanCellRenderer
563
- value={false}
564
- field={{ name: 'active', type: 'boolean', label: 'Active' } as any}
565
- />
566
- );
567
- const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
568
- expect(badge).toBeInTheDocument();
569
- expect(badge?.textContent).toContain('Off');
570
- });
571
-
572
- it('should render warning badge for is_enabled=false', () => {
573
- const { container } = render(
574
- <BooleanCellRenderer
575
- value={false}
576
- field={{ name: 'is_enabled', type: 'boolean', label: 'Enabled' } as any}
577
- />
578
- );
579
- const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
580
- expect(badge).toBeInTheDocument();
581
- });
582
-
583
- it('should render normal checkbox for active=true', () => {
584
- render(
585
- <BooleanCellRenderer
586
- value={true}
587
- field={{ name: 'active', type: 'boolean' } as any}
588
- />
589
- );
590
- const checkbox = screen.getByRole('checkbox');
591
- expect(checkbox).toHaveAttribute('data-state', 'checked');
592
- });
593
- });
594
-
595
- // =========================================================================
596
- // 4c. EmailCellRenderer — mailto + copy button
597
- // =========================================================================
598
- describe('EmailCellRenderer', () => {
599
- it('should render mailto link', () => {
600
- render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
601
- const link = screen.getByRole('link');
602
- expect(link).toHaveAttribute('href', 'mailto:test@example.com');
603
- expect(screen.getByText('test@example.com')).toBeInTheDocument();
604
- });
605
-
606
- it('should render copy button', () => {
607
- render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
608
- const copyBtn = screen.getByLabelText('Copy email');
609
- expect(copyBtn).toBeInTheDocument();
610
- });
611
-
612
- it('should render dash for empty value', () => {
613
- render(<EmailCellRenderer value={null} field={{ name: 'email', type: 'email' } as any} />);
614
- expect(screen.getByText('—')).toBeInTheDocument();
615
- });
616
- });
617
-
618
- // =========================================================================
619
- // 4d. PhoneCellRenderer — tel link + call icon + copy
620
- // =========================================================================
621
- describe('PhoneCellRenderer', () => {
622
- it('should render tel link with phone icon', () => {
623
- render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
624
- const link = screen.getByRole('link');
625
- expect(link).toHaveAttribute('href', 'tel:+1-555-1234');
626
- expect(screen.getByText('+1-555-1234')).toBeInTheDocument();
627
- });
628
-
629
- it('should render copy button', () => {
630
- render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
631
- const copyBtn = screen.getByLabelText('Copy phone number');
632
- expect(copyBtn).toBeInTheDocument();
633
- });
634
-
635
- it('should render dash for empty value', () => {
636
- render(<PhoneCellRenderer value={null} field={{ name: 'phone', type: 'phone' } as any} />);
637
- expect(screen.getByText('—')).toBeInTheDocument();
638
- });
639
- });
640
-
641
- // =========================================================================
642
- // 5. formatRelativeDate edge cases
643
- // =========================================================================
644
- describe('formatRelativeDate', () => {
645
- let nowSpy: ReturnType<typeof vi.spyOn>;
646
-
647
- beforeEach(() => {
648
- nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-02-24T12:00:00Z').getTime());
649
- });
650
-
651
- afterEach(() => {
652
- nowSpy.mockRestore();
653
- });
654
-
655
- it('should return "-" for null', () => {
656
- expect(formatRelativeDate(null as any)).toBe('—');
657
- });
658
-
659
- it('should return "-" for empty string', () => {
660
- expect(formatRelativeDate('')).toBe('—');
661
- });
662
-
663
- it('should return "-" for invalid date', () => {
664
- expect(formatRelativeDate('not-a-date')).toBe('—');
665
- });
666
-
667
- it('should return "Today" for today', () => {
668
- const today = new Date();
669
- expect(formatRelativeDate(today)).toBe('Today');
670
- });
671
-
672
- it('should return "Yesterday" for yesterday', () => {
673
- const yesterday = new Date();
674
- yesterday.setDate(yesterday.getDate() - 1);
675
- expect(formatRelativeDate(yesterday)).toBe('Yesterday');
676
- });
677
-
678
- it('should return "Tomorrow" for tomorrow', () => {
679
- const tomorrow = new Date();
680
- tomorrow.setDate(tomorrow.getDate() + 1);
681
- expect(formatRelativeDate(tomorrow)).toBe('Tomorrow');
682
- });
683
-
684
- it('should return "Overdue Xd" for 2-7 days ago', () => {
685
- const threeDaysAgo = new Date();
686
- threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
687
- expect(formatRelativeDate(threeDaysAgo)).toBe('Overdue 3d');
688
- });
689
-
690
- it('should return formatted date for >7 days ago', () => {
691
- const tenDaysAgo = new Date();
692
- tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
693
- const result = formatRelativeDate(tenDaysAgo);
694
- const expected = tenDaysAgo.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
695
- expect(result).toBe(expected);
696
- });
697
-
698
- it('should return "In X days" for 2-7 days in the future', () => {
699
- const fourDaysFromNow = new Date();
700
- fourDaysFromNow.setDate(fourDaysFromNow.getDate() + 4);
701
- expect(formatRelativeDate(fourDaysFromNow)).toBe('In 4 days');
702
- });
703
-
704
- it('should return formatted date for >7 days in the future', () => {
705
- const fifteenDaysFromNow = new Date();
706
- fifteenDaysFromNow.setDate(fifteenDaysFromNow.getDate() + 15);
707
- const result = formatRelativeDate(fifteenDaysFromNow);
708
- const expected = fifteenDaysFromNow.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
709
- expect(result).toBe(expected);
710
- });
711
- });
712
-
713
- // =========================================================================
714
- // 6. formatDate with relative style
715
- // =========================================================================
716
- describe('formatDate', () => {
717
- it('should return "-" for null', () => {
718
- expect(formatDate(null as any)).toBe('—');
719
- });
720
-
721
- it('should return "-" for invalid date', () => {
722
- expect(formatDate('invalid')).toBe('—');
723
- });
724
-
725
- it('should format as relative when style is "relative"', () => {
726
- // Just ensure it delegates to formatRelativeDate
727
- const result = formatDate(new Date(), 'relative');
728
- expect(result).toBe('Today');
729
- });
730
-
731
- it('should format in short style', () => {
732
- const result = formatDate('2026-01-15T08:00:00Z', 'short');
733
- expect(result).toBe("Jan 15, '26");
734
- });
735
-
736
- it('should format in default style (MMM DD, YYYY)', () => {
737
- const result = formatDate('2026-01-15T08:00:00Z');
738
- expect(result).toContain('Jan');
739
- expect(result).toContain('15');
740
- expect(result).toContain('2026');
741
- });
742
- });
743
-
744
- // =========================================================================
745
- // 7. LookupCellRenderer
746
- // =========================================================================
747
- describe('LookupCellRenderer', () => {
748
- it('should render dash for null value', () => {
749
- render(
750
- <LookupCellRenderer
751
- value={null}
752
- field={{ name: 'customer', type: 'lookup' } as any}
753
- />
754
- );
755
- expect(screen.getByText('—')).toBeInTheDocument();
756
- });
757
-
758
- it('should render dash for empty string', () => {
759
- render(
760
- <LookupCellRenderer
761
- value=""
762
- field={{ name: 'customer', type: 'lookup' } as any}
763
- />
764
- );
765
- expect(screen.getByText('—')).toBeInTheDocument();
766
- });
767
-
768
- it('should resolve primitive ID to label via field options', () => {
769
- render(
770
- <LookupCellRenderer
771
- value={2}
772
- field={{
773
- name: 'customer',
774
- type: 'lookup',
775
- options: [
776
- { value: 1, label: 'Alice' },
777
- { value: 2, label: 'Bob' },
778
- ],
779
- } as any}
780
- />
781
- );
782
- expect(screen.getByText('Bob')).toBeInTheDocument();
783
- });
784
-
785
- it('should resolve string ID to label via field options', () => {
786
- render(
787
- <LookupCellRenderer
788
- value="contact_1"
789
- field={{
790
- name: 'customer',
791
- type: 'lookup',
792
- options: [{ value: 'contact_1', label: 'Alice Smith' }],
793
- } as any}
794
- />
795
- );
796
- expect(screen.getByText('Alice Smith')).toBeInTheDocument();
797
- });
798
-
799
- it('should render raw primitive when no options available', () => {
800
- render(
801
- <LookupCellRenderer
802
- value={42}
803
- field={{ name: 'customer', type: 'lookup' } as any}
804
- />
805
- );
806
- expect(screen.getByText('42')).toBeInTheDocument();
807
- });
808
-
809
- it('should render object name when value is an object', () => {
810
- render(
811
- <LookupCellRenderer
812
- value={{ name: 'Acme Corp', id: '123' }}
813
- field={{ name: 'account', type: 'lookup' } as any}
814
- />
815
- );
816
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
817
- });
818
-
819
- it('should render object label when name is missing', () => {
820
- render(
821
- <LookupCellRenderer
822
- value={{ label: 'Widget Co', id: '456' }}
823
- field={{ name: 'account', type: 'lookup' } as any}
824
- />
825
- );
826
- expect(screen.getByText('Widget Co')).toBeInTheDocument();
827
- });
828
-
829
- it('should render object id as fallback', () => {
830
- render(
831
- <LookupCellRenderer
832
- value={{ id: '789' }}
833
- field={{ name: 'account', type: 'lookup' } as any}
834
- />
835
- );
836
- expect(screen.getByText('789')).toBeInTheDocument();
837
- });
838
-
839
- it('should render tags for array of objects', () => {
840
- render(
841
- <LookupCellRenderer
842
- value={[{ name: 'Alice' }, { name: 'Bob' }]}
843
- field={{ name: 'contacts', type: 'lookup' } as any}
844
- />
845
- );
846
- expect(screen.getByText('Alice')).toBeInTheDocument();
847
- expect(screen.getByText('Bob')).toBeInTheDocument();
848
- });
849
-
850
- it('should resolve array of primitive IDs via options', () => {
851
- render(
852
- <LookupCellRenderer
853
- value={[1, 3]}
854
- field={{
855
- name: 'contacts',
856
- type: 'lookup',
857
- options: [
858
- { value: 1, label: 'Alice' },
859
- { value: 2, label: 'Bob' },
860
- { value: 3, label: 'Charlie' },
861
- ],
862
- } as any}
863
- />
864
- );
865
- expect(screen.getByText('Alice')).toBeInTheDocument();
866
- expect(screen.getByText('Charlie')).toBeInTheDocument();
867
- expect(screen.queryByText('Bob')).not.toBeInTheDocument();
868
- });
869
- });
870
-
871
- // =========================================================================
872
- // 8. UserCellRenderer
873
- // =========================================================================
874
- describe('UserCellRenderer', () => {
875
- it('should render dash for null value', () => {
876
- render(
877
- <UserCellRenderer
878
- value={null}
879
- field={{ name: 'owner', type: 'user' } as any}
880
- />
881
- );
882
- expect(screen.getByText('—')).toBeInTheDocument();
883
- });
884
-
885
- it('should render text for primitive user ID (number)', () => {
886
- render(
887
- <UserCellRenderer
888
- value={5}
889
- field={{ name: 'owner', type: 'user' } as any}
890
- />
891
- );
892
- expect(screen.getByText('5')).toBeInTheDocument();
893
- });
894
-
895
- it('should render text for primitive user ID (string)', () => {
896
- render(
897
- <UserCellRenderer
898
- value="user_abc"
899
- field={{ name: 'owner', type: 'user' } as any}
900
- />
901
- );
902
- expect(screen.getByText('user_abc')).toBeInTheDocument();
903
- });
904
-
905
- it('should render avatar and name for object value', () => {
906
- render(
907
- <UserCellRenderer
908
- value={{ name: 'John Doe', username: 'jdoe' }}
909
- field={{ name: 'owner', type: 'user' } as any}
910
- />
911
- );
912
- expect(screen.getByText('John Doe')).toBeInTheDocument();
913
- });
914
-
915
- it('should use username when name is missing', () => {
916
- render(
917
- <UserCellRenderer
918
- value={{ username: 'jdoe' }}
919
- field={{ name: 'owner', type: 'user' } as any}
920
- />
921
- );
922
- expect(screen.getByText('jdoe')).toBeInTheDocument();
923
- });
924
-
925
- it('should render multiple avatars for array of user objects', () => {
926
- render(
927
- <UserCellRenderer
928
- value={[{ name: 'Alice' }, { name: 'Bob' }]}
929
- field={{ name: 'assignees', type: 'user' } as any}
930
- />
931
- );
932
- // Avatar fallbacks contain initials
933
- expect(screen.getByTitle('Alice')).toBeInTheDocument();
934
- expect(screen.getByTitle('Bob')).toBeInTheDocument();
935
- });
936
- });
937
-
938
- // =========================================================================
939
- // 9. humanizeLabel
940
- // =========================================================================
941
-
942
- describe('humanizeLabel', () => {
943
- it('should convert snake_case to Title Case', () => {
944
- expect(humanizeLabel('in_progress')).toBe('In Progress');
945
- });
946
-
947
- it('should convert kebab-case to Title Case', () => {
948
- expect(humanizeLabel('high-priority')).toBe('High Priority');
949
- });
950
-
951
- it('should handle single word', () => {
952
- expect(humanizeLabel('active')).toBe('Active');
953
- });
954
-
955
- it('should handle already Title Case', () => {
956
- expect(humanizeLabel('Active')).toBe('Active');
957
- });
958
-
959
- it('should handle multiple underscores', () => {
960
- expect(humanizeLabel('not_yet_started')).toBe('Not Yet Started');
961
- });
962
-
963
- it('should handle empty string', () => {
964
- expect(humanizeLabel('')).toBe('');
965
- });
966
-
967
- it('should handle mixed separators', () => {
968
- expect(humanizeLabel('in_progress-now')).toBe('In Progress Now');
969
- });
970
- });
971
-
972
- // =========================================================================
973
- // 10. SelectCellRenderer humanizeLabel fallback
974
- // =========================================================================
975
- describe('SelectCellRenderer humanizeLabel fallback', () => {
976
- it('should humanize snake_case value when no option label exists', () => {
977
- render(
978
- <SelectCellRenderer
979
- value="in_progress"
980
- field={{ name: 'status', type: 'select', options: [] } as any}
981
- />
982
- );
983
- expect(screen.getByText('In Progress')).toBeInTheDocument();
984
- });
985
-
986
- it('should prefer explicit option.label over humanized fallback', () => {
987
- render(
988
- <SelectCellRenderer
989
- value="in_progress"
990
- field={{
991
- name: 'status',
992
- type: 'select',
993
- options: [{ value: 'in_progress', label: 'WIP' }],
994
- } as any}
995
- />
996
- );
997
- expect(screen.getByText('WIP')).toBeInTheDocument();
998
- expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
999
- });
1000
-
1001
- it('should humanize snake_case values in arrays', () => {
1002
- render(
1003
- <SelectCellRenderer
1004
- value={['not_started', 'in_progress']}
1005
- field={{ name: 'status', type: 'select', options: [] } as any}
1006
- />
1007
- );
1008
- expect(screen.getByText('Not Started')).toBeInTheDocument();
1009
- expect(screen.getByText('In Progress')).toBeInTheDocument();
1010
- });
1011
- });
1012
-
1013
- // =========================================================================
1014
- // 11. PercentCellRenderer with progress-type fields
1015
- // =========================================================================
1016
- describe('PercentCellRenderer progress-type fields', () => {
1017
- it('should render progress field value 75 as 75% with correct bar', () => {
1018
- const { container } = render(
1019
- <PercentCellRenderer
1020
- value={75}
1021
- field={{ name: 'progress', type: 'percent' } as any}
1022
- />
1023
- );
1024
- expect(screen.getByText('75%')).toBeInTheDocument();
1025
- const bar = container.querySelector('[role="progressbar"]');
1026
- expect(bar).toHaveAttribute('aria-valuenow', '75');
1027
- });
1028
-
1029
- it('should render completion field value 50 as 50% with correct bar', () => {
1030
- const { container } = render(
1031
- <PercentCellRenderer
1032
- value={50}
1033
- field={{ name: 'completion', type: 'percent' } as any}
1034
- />
1035
- );
1036
- expect(screen.getByText('50%')).toBeInTheDocument();
1037
- const bar = container.querySelector('[role="progressbar"]');
1038
- expect(bar).toHaveAttribute('aria-valuenow', '50');
1039
- });
1040
-
1041
- it('should render probability field value 0.75 as 75%', () => {
1042
- const { container } = render(
1043
- <PercentCellRenderer
1044
- value={0.75}
1045
- field={{ name: 'probability', type: 'percent' } as any}
1046
- />
1047
- );
1048
- expect(screen.getByText('75%')).toBeInTheDocument();
1049
- const bar = container.querySelector('[role="progressbar"]');
1050
- expect(bar).toHaveAttribute('aria-valuenow', '75');
1051
- });
1052
-
1053
- it('should render rate field value 0.5 as 50%', () => {
1054
- const { container } = render(
1055
- <PercentCellRenderer
1056
- value={0.5}
1057
- field={{ name: 'rate', type: 'percent' } as any}
1058
- />
1059
- );
1060
- expect(screen.getByText('50%')).toBeInTheDocument();
1061
- const bar = container.querySelector('[role="progressbar"]');
1062
- expect(bar).toHaveAttribute('aria-valuenow', '50');
1063
- });
1064
- });
1065
-
1066
- // =========================================================================
1067
- // Object value safety (coerceToSafeValue) — prevents React error #310
1068
- // =========================================================================
1069
- import {
1070
- NumberCellRenderer,
1071
- CurrencyCellRenderer,
1072
- FormulaCellRenderer,
1073
- coerceToSafeValue,
1074
- } from '../index';
1075
-
1076
- describe('coerceToSafeValue', () => {
1077
- it('should pass through primitives unchanged', () => {
1078
- expect(coerceToSafeValue('hello')).toBe('hello');
1079
- expect(coerceToSafeValue(42)).toBe(42);
1080
- expect(coerceToSafeValue(true)).toBe(true);
1081
- expect(coerceToSafeValue(null)).toBe(null);
1082
- expect(coerceToSafeValue(undefined)).toBe(undefined);
1083
- });
1084
-
1085
- it('should extract number from MongoDB $numberDecimal', () => {
1086
- expect(coerceToSafeValue({ $numberDecimal: '250000' })).toBe(250000);
1087
- });
1088
-
1089
- it('should extract string from MongoDB $oid', () => {
1090
- expect(coerceToSafeValue({ $oid: 'abc123' })).toBe('abc123');
1091
- });
1092
-
1093
- it('should extract string from MongoDB $date', () => {
1094
- expect(coerceToSafeValue({ $date: '2024-01-01T00:00:00Z' })).toBe('2024-01-01T00:00:00Z');
1095
- });
1096
-
1097
- it('should extract name from expanded reference object', () => {
1098
- expect(coerceToSafeValue({ id: 'x', name: 'Acme Corp' })).toBe('Acme Corp');
1099
- });
1100
-
1101
- it('should extract label when name is not present', () => {
1102
- expect(coerceToSafeValue({ id: 'x', label: 'Active' })).toBe('Active');
1103
- });
1104
-
1105
- it('should fall back to id when no name/label', () => {
1106
- expect(coerceToSafeValue({ id: 'abc123' })).toBe('abc123');
1107
- });
1108
-
1109
- it('should handle arrays of primitives', () => {
1110
- expect(coerceToSafeValue(['a', 'b', 'c'])).toBe('a, b, c');
1111
- });
1112
-
1113
- it('should handle arrays of objects', () => {
1114
- expect(coerceToSafeValue([{ name: 'Alice' }, { name: 'Bob' }])).toBe('Alice, Bob');
1115
- });
1116
-
1117
- it('should convert Date to ISO string', () => {
1118
- const d = new Date('2024-06-15T12:00:00Z');
1119
- expect(coerceToSafeValue(d)).toBe('2024-06-15T12:00:00.000Z');
1120
- });
1121
- });
1122
-
1123
- describe('NumberCellRenderer object safety', () => {
1124
- it('should handle MongoDB $numberDecimal without crashing', () => {
1125
- const { container } = render(
1126
- <NumberCellRenderer
1127
- value={{ $numberDecimal: '250000' }}
1128
- field={{ name: 'amount', type: 'number' } as any}
1129
- />
1130
- );
1131
- expect(container.innerHTML).not.toBe('');
1132
- expect(screen.getByText('250,000')).toBeInTheDocument();
1133
- });
1134
-
1135
- it('should handle expanded reference object without crashing', () => {
1136
- const { container } = render(
1137
- <NumberCellRenderer
1138
- value={{ id: 'abc', name: 'Not a number' }}
1139
- field={{ name: 'amount', type: 'number' } as any}
1140
- />
1141
- );
1142
- expect(container.innerHTML).not.toBe('');
1143
- // Should render the extracted name string, not crash
1144
- expect(screen.getByText('Not a number')).toBeInTheDocument();
1145
- });
1146
- });
1147
-
1148
- describe('CurrencyCellRenderer object safety', () => {
1149
- it('should handle MongoDB $numberDecimal', () => {
1150
- const { container } = render(
1151
- <CurrencyCellRenderer
1152
- value={{ $numberDecimal: '5000' }}
1153
- field={{ name: 'price', type: 'currency', currency: 'USD' } as any}
1154
- />
1155
- );
1156
- expect(container.innerHTML).not.toBe('');
1157
- expect(screen.getByText(/5,000/)).toBeInTheDocument();
1158
- });
1159
- });
1160
-
1161
- describe('TextCellRenderer object safety', () => {
1162
- it('should extract name from object instead of [object Object]', () => {
1163
- render(
1164
- <TextCellRenderer
1165
- value={{ id: 'abc', name: 'Acme Corp' }}
1166
- field={{ name: 'company', type: 'text' } as any}
1167
- />
1168
- );
1169
- expect(screen.getByText('Acme Corp')).toBeInTheDocument();
1170
- });
1171
-
1172
- it('should handle arrays of objects', () => {
1173
- render(
1174
- <TextCellRenderer
1175
- value={[{ name: 'Alice' }, { name: 'Bob' }]}
1176
- field={{ name: 'contacts', type: 'text' } as any}
1177
- />
1178
- );
1179
- expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
1180
- });
1181
- });
1182
-
1183
- describe('FormulaCellRenderer object safety', () => {
1184
- it('should extract value from MongoDB $numberDecimal', () => {
1185
- render(
1186
- <FormulaCellRenderer
1187
- value={{ $numberDecimal: '42.5' }}
1188
- field={{ name: 'calc', type: 'formula' } as any}
1189
- />
1190
- );
1191
- expect(screen.getByText('42.5')).toBeInTheDocument();
1192
- });
1193
- });