@object-ui/fields 3.0.2 → 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.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * DateTimeCellRenderer Tests
3
+ *
4
+ * Tests for the Airtable-style split date/time cell renderer.
5
+ */
6
+ import { describe, it, expect } from 'vitest';
7
+ import { render, screen } from '@testing-library/react';
8
+ import '@testing-library/jest-dom';
9
+ import React from 'react';
10
+ import { DateTimeCellRenderer } from '../index';
11
+
12
+ describe('DateTimeCellRenderer', () => {
13
+ it('should render date and time separately', () => {
14
+ render(
15
+ <DateTimeCellRenderer
16
+ value="2026-02-18T12:57:00.000Z"
17
+ field={{ name: 'created_at', type: 'datetime' } as any}
18
+ />
19
+ );
20
+ // Date part should be visible
21
+ expect(screen.getByText('2/18/2026')).toBeInTheDocument();
22
+ // Time part should be in a muted span
23
+ const container = screen.getByText('2/18/2026').closest('span');
24
+ expect(container).toBeInTheDocument();
25
+ });
26
+
27
+ it('should show dash for null value', () => {
28
+ const { container } = render(
29
+ <DateTimeCellRenderer
30
+ value={null}
31
+ field={{ name: 'created_at', type: 'datetime' } as any}
32
+ />
33
+ );
34
+ expect(container.textContent).toBe('-');
35
+ });
36
+
37
+ it('should show dash for invalid date', () => {
38
+ const { container } = render(
39
+ <DateTimeCellRenderer
40
+ value="not-a-date"
41
+ field={{ name: 'created_at', type: 'datetime' } as any}
42
+ />
43
+ );
44
+ expect(container.textContent).toBe('-');
45
+ });
46
+ });
package/src/index.tsx CHANGED
@@ -9,8 +9,8 @@
9
9
  import React from 'react';
10
10
  import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
11
11
  import { ComponentRegistry } from '@object-ui/core';
12
- import { Badge, Avatar, AvatarFallback, Button } from '@object-ui/components';
13
- import { Check, X } from 'lucide-react';
12
+ import { Badge, Avatar, AvatarFallback, Button, Checkbox } from '@object-ui/components';
13
+ import { Check, X, Copy, Phone as PhoneIcon } from 'lucide-react';
14
14
 
15
15
  import { TextField } from './widgets/TextField';
16
16
  import { NumberField } from './widgets/NumberField';
@@ -74,6 +74,26 @@ export function formatCurrency(value: number, currency: string = 'USD'): string
74
74
  }
75
75
  }
76
76
 
77
+ /**
78
+ * Format currency value in compact form for mobile display.
79
+ * E.g., $150,000 → $150K, $1,200,000 → $1.2M
80
+ */
81
+ export function formatCompactCurrency(value: number, currency: string = 'USD'): string {
82
+ try {
83
+ const formatted = new Intl.NumberFormat('en-US', {
84
+ style: 'currency',
85
+ currency,
86
+ notation: 'compact',
87
+ maximumFractionDigits: 1,
88
+ }).format(value);
89
+ // Strip trailing ".0" before compact suffix for consistent cross-environment output
90
+ // e.g. "$150.0K" → "$150K" while keeping "$1.5M" intact
91
+ return formatted.replace(/\.0(?=[KMBT])/, '');
92
+ } catch {
93
+ return `${currency} ${value}`;
94
+ }
95
+ }
96
+
77
97
  /**
78
98
  * Format percent value
79
99
  * Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
@@ -84,16 +104,67 @@ export function formatPercent(value: number, precision: number = 0): string {
84
104
  return `${displayValue.toFixed(precision)}%`;
85
105
  }
86
106
 
107
+ /**
108
+ * Humanize a snake_case or kebab-case string into Title Case.
109
+ * Used as fallback label when no explicit option.label exists.
110
+ *
111
+ * Examples:
112
+ * "in_progress" → "In Progress"
113
+ * "high-priority" → "High Priority"
114
+ * "active" → "Active"
115
+ */
116
+ export function humanizeLabel(value: string): string {
117
+ return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
118
+ }
119
+
120
+ /**
121
+ * Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
122
+ */
123
+ export function formatRelativeDate(value: string | Date): string {
124
+ if (!value) return '-';
125
+ const date = typeof value === 'string' ? new Date(value) : value;
126
+ if (isNaN(date.getTime())) return '-';
127
+
128
+ const now = new Date();
129
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
130
+ const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
131
+ const diffMs = startOfDate.getTime() - startOfToday.getTime();
132
+ const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
133
+
134
+ if (diffDays === 0) return 'Today';
135
+ if (diffDays === 1) return 'Tomorrow';
136
+ if (diffDays === -1) return 'Yesterday';
137
+ if (diffDays < -1) {
138
+ const absDays = Math.abs(diffDays);
139
+ if (absDays <= 7) return `Overdue ${absDays}d`;
140
+ return formatDate(date);
141
+ }
142
+ if (diffDays > 1 && diffDays <= 7) return `In ${diffDays} days`;
143
+ return formatDate(date);
144
+ }
145
+
87
146
  /**
88
147
  * Format date value
89
148
  */
90
- export function formatDate(value: string | Date, _format?: string): string {
149
+ export function formatDate(value: string | Date, style?: string): string {
91
150
  if (!value) return '-';
92
151
  const date = typeof value === 'string' ? new Date(value) : value;
93
152
  if (isNaN(date.getTime())) return '-';
153
+
154
+ if (style === 'short') {
155
+ // Compact format for mobile: "Jan 15, '24"
156
+ const month = date.toLocaleDateString('en-US', { month: 'short' });
157
+ const day = date.getDate();
158
+ const year = String(date.getFullYear()).slice(-2);
159
+ return `${month} ${day}, '${year}`;
160
+ }
161
+
162
+ if (style === 'relative') {
163
+ return formatRelativeDate(date);
164
+ }
94
165
 
95
- // Default format: MMM DD, YYYY
96
- return date.toLocaleDateString('en-US', {
166
+ // Default format: locale-aware human-readable (e.g. "Jan 15, 2024" or "2024/1/15")
167
+ return date.toLocaleDateString(undefined, {
97
168
  year: 'numeric',
98
169
  month: 'short',
99
170
  day: 'numeric',
@@ -108,7 +179,7 @@ export function formatDateTime(value: string | Date): string {
108
179
  const date = typeof value === 'string' ? new Date(value) : value;
109
180
  if (isNaN(date.getTime())) return '-';
110
181
 
111
- return date.toLocaleDateString('en-US', {
182
+ return date.toLocaleDateString(undefined, {
112
183
  year: 'numeric',
113
184
  month: 'short',
114
185
  day: 'numeric',
@@ -121,7 +192,7 @@ export function formatDateTime(value: string | Date): string {
121
192
  * Text field cell renderer
122
193
  */
123
194
  export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
124
- return <span className="truncate">{value || '-'}</span>;
195
+ return <span className="truncate">{(value != null && value !== '') ? String(value) : '-'}</span>;
125
196
  }
126
197
 
127
198
  /**
@@ -152,6 +223,9 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
152
223
  return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
153
224
  }
154
225
 
226
+ // Fields that store percentage values as whole numbers (0-100) rather than fractions (0-1)
227
+ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
228
+
155
229
  /**
156
230
  * Percent field cell renderer with mini progress bar
157
231
  */
@@ -161,9 +235,13 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
161
235
  const percentField = field as any;
162
236
  const precision = percentField.precision ?? 0;
163
237
  const numValue = Number(value);
164
- const formatted = formatPercent(numValue, precision);
165
- // Normalize to 0-100 range for progress bar
166
- const barValue = (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
238
+ // Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
239
+ // Fields like "progress" or "completion" store values as 0-100, not 0-1
240
+ const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || '');
241
+ const barValue = isWholePercentField
242
+ ? numValue
243
+ : (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
244
+ const formatted = isWholePercentField ? `${numValue.toFixed(precision)}%` : formatPercent(numValue, precision);
167
245
  const clampedBar = Math.max(0, Math.min(100, barValue));
168
246
 
169
247
  return (
@@ -185,23 +263,53 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
185
263
  );
186
264
  }
187
265
 
266
+ /** Field names that trigger warning badge when boolean value is false */
267
+ const STATUS_FIELD_NAMES = new Set([
268
+ 'active', 'is_active', 'enabled', 'is_enabled', 'verified', 'is_verified',
269
+ ]);
270
+
188
271
  /**
189
- * Boolean field cell renderer
272
+ * Boolean field cell renderer (Airtable-style checkbox)
273
+ * Supports semantic rendering for completion fields (green indicator)
274
+ * and warning badge for active/enabled fields when false.
190
275
  */
191
- export function BooleanCellRenderer({ value }: CellRendererProps): React.ReactElement {
276
+ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
277
+ if (value == null) {
278
+ return <span className="text-muted-foreground/50 text-xs italic flex items-center justify-center">—</span>;
279
+ }
280
+
281
+ // Semantic rendering for completion fields (green circle indicator)
282
+ // Only match exact field names to avoid false positives
283
+ const fieldName = field?.name?.toLowerCase() || '';
284
+ const isCompletionField = fieldName === 'completed' || fieldName === 'is_completed'
285
+ || fieldName === 'done' || fieldName === 'is_done';
286
+
287
+ if (isCompletionField) {
288
+ return (
289
+ <div className="flex items-center justify-center">
290
+ {value ? (
291
+ <div className="size-5 rounded-full bg-green-500 flex items-center justify-center" role="img" aria-label="Completed" data-testid="completion-indicator">
292
+ <Check className="size-3 text-white" />
293
+ </div>
294
+ ) : (
295
+ <div className="size-5 rounded-full border-2 border-muted-foreground/30" role="img" aria-label="Not completed" data-testid="completion-indicator" />
296
+ )}
297
+ </div>
298
+ );
299
+ }
300
+
301
+ // Warning badge for active/enabled fields when false
302
+ if (STATUS_FIELD_NAMES.has(fieldName) && !value) {
303
+ return (
304
+ <Badge variant="destructive" className="text-xs" data-testid="boolean-warning-badge">
305
+ {field?.label || humanizeLabel(fieldName)} — Off
306
+ </Badge>
307
+ );
308
+ }
309
+
192
310
  return (
193
- <div className="flex items-center">
194
- {value ? (
195
- <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 gap-1">
196
- <Check className="size-3" />
197
- True
198
- </Badge>
199
- ) : (
200
- <Badge variant="outline" className="bg-gray-50 text-gray-500 border-gray-200 gap-1">
201
- <X className="size-3" />
202
- False
203
- </Badge>
204
- )}
311
+ <div className="flex items-center justify-center">
312
+ <Checkbox checked={!!value} disabled className="pointer-events-none" />
205
313
  </div>
206
314
  );
207
315
  }
@@ -210,19 +318,84 @@ export function BooleanCellRenderer({ value }: CellRendererProps): React.ReactEl
210
318
  * Date field cell renderer
211
319
  */
212
320
  export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
321
+ if (!value) return <span className="text-muted-foreground">-</span>;
213
322
  const dateField = field as any;
214
- const formatted = formatDate(value, dateField.format);
323
+ const style = dateField.format || 'relative';
324
+ const formatted = formatDate(value, style);
215
325
 
216
- return <span className="tabular-nums">{formatted}</span>;
326
+ // Determine if date is overdue (in the past)
327
+ const date = typeof value === 'string' ? new Date(value) : value;
328
+ const isValidDate = date instanceof Date && !isNaN(date.getTime());
329
+ const startOfToday = new Date();
330
+ startOfToday.setHours(0, 0, 0, 0);
331
+ const isOverdue = isValidDate && date < startOfToday;
332
+ const isoString = isValidDate ? date.toISOString() : String(value);
333
+
334
+ return (
335
+ <span
336
+ className={`tabular-nums${isOverdue ? ' text-red-600' : ''}`}
337
+ title={isoString}
338
+ >
339
+ {formatted}
340
+ </span>
341
+ );
217
342
  }
218
343
 
219
344
  /**
220
- * DateTime field cell renderer
345
+ * DateTime field cell renderer (Airtable-style with date and time visually separated)
221
346
  */
222
347
  export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
223
- const formatted = formatDateTime(value);
224
-
225
- return <span className="tabular-nums text-sm">{formatted}</span>;
348
+ if (!value) return <span className="text-muted-foreground">-</span>;
349
+ const date = typeof value === 'string' ? new Date(value) : value;
350
+ if (isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
351
+
352
+ const datePart = date.toLocaleDateString(undefined, {
353
+ month: 'numeric',
354
+ day: 'numeric',
355
+ year: 'numeric',
356
+ });
357
+ const timePart = date.toLocaleTimeString(undefined, {
358
+ hour: 'numeric',
359
+ minute: '2-digit',
360
+ hour12: true,
361
+ }).toLowerCase();
362
+
363
+ return (
364
+ <span className="tabular-nums text-sm whitespace-nowrap">
365
+ <span>{datePart}</span>
366
+ <span className="ml-2 text-muted-foreground">{timePart}</span>
367
+ </span>
368
+ );
369
+ }
370
+
371
+ // Priority semantic color mapping (auto-detect from value text)
372
+ const PRIORITY_COLOR_MAP: Record<string, string> = {
373
+ critical: 'red',
374
+ urgent: 'red',
375
+ high: 'orange',
376
+ medium: 'yellow',
377
+ normal: 'blue',
378
+ low: 'gray',
379
+ none: 'gray',
380
+ };
381
+
382
+ // Color to Tailwind class mapping for custom Badge styling
383
+ const BADGE_COLOR_MAP: Record<string, string> = {
384
+ gray: 'bg-gray-100 text-gray-800 border-gray-300',
385
+ red: 'bg-red-100 text-red-800 border-red-300',
386
+ orange: 'bg-orange-100 text-orange-800 border-orange-300',
387
+ yellow: 'bg-yellow-100 text-yellow-800 border-yellow-300',
388
+ green: 'bg-green-100 text-green-800 border-green-300',
389
+ blue: 'bg-blue-100 text-blue-800 border-blue-300',
390
+ indigo: 'bg-indigo-100 text-indigo-800 border-indigo-300',
391
+ purple: 'bg-purple-100 text-purple-800 border-purple-300',
392
+ pink: 'bg-pink-100 text-pink-800 border-pink-300',
393
+ };
394
+
395
+ function getBadgeColorClasses(color?: string, val?: string): string {
396
+ const resolvedColor = color
397
+ || (val ? PRIORITY_COLOR_MAP[String(val).toLowerCase()] : undefined);
398
+ return BADGE_COLOR_MAP[resolvedColor || ''] || 'bg-muted text-muted-foreground border-border';
226
399
  }
227
400
 
228
401
  /**
@@ -234,30 +407,14 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
234
407
 
235
408
  if (!value) return <span>-</span>;
236
409
 
237
- // Color to Tailwind class mapping for custom Badge styling
238
- const getColorClasses = (color?: string) => {
239
- const colorMap: Record<string, string> = {
240
- gray: 'bg-gray-100 text-gray-800 border-gray-300',
241
- red: 'bg-red-100 text-red-800 border-red-300',
242
- orange: 'bg-orange-100 text-orange-800 border-orange-300',
243
- yellow: 'bg-yellow-100 text-yellow-800 border-yellow-300',
244
- green: 'bg-green-100 text-green-800 border-green-300',
245
- blue: 'bg-blue-100 text-blue-800 border-blue-300',
246
- indigo: 'bg-indigo-100 text-indigo-800 border-indigo-300',
247
- purple: 'bg-purple-100 text-purple-800 border-purple-300',
248
- pink: 'bg-pink-100 text-pink-800 border-pink-300',
249
- };
250
- return colorMap[color || 'blue'] || colorMap.blue;
251
- };
252
-
253
410
  // Handle multiple values
254
411
  if (Array.isArray(value)) {
255
412
  return (
256
413
  <div className="flex flex-wrap gap-1">
257
414
  {value.map((val, idx) => {
258
415
  const option = options.find(opt => opt.value === val);
259
- const label = option?.label || val;
260
- const colorClasses = getColorClasses(option?.color);
416
+ const label = option?.label || humanizeLabel(String(val));
417
+ const colorClasses = getBadgeColorClasses(option?.color, val);
261
418
 
262
419
  return (
263
420
  <Badge
@@ -275,8 +432,8 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
275
432
 
276
433
  // Handle single value
277
434
  const option = options.find(opt => opt.value === value);
278
- const label = option?.label || value;
279
- const colorClasses = getColorClasses(option?.color);
435
+ const label = option?.label || humanizeLabel(String(value));
436
+ const colorClasses = getBadgeColorClasses(option?.color, value);
280
437
 
281
438
  return (
282
439
  <Badge
@@ -294,19 +451,44 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
294
451
  export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
295
452
  if (!value) return <span>-</span>;
296
453
 
454
+ const [copied, setCopied] = React.useState(false);
455
+
456
+ const handleCopy = (e: React.MouseEvent) => {
457
+ e.stopPropagation();
458
+ e.preventDefault();
459
+ navigator.clipboard.writeText(String(value)).then(() => {
460
+ setCopied(true);
461
+ setTimeout(() => setCopied(false), 2000);
462
+ }).catch(() => { /* clipboard not available */ });
463
+ };
464
+
297
465
  return (
298
- <Button
299
- variant="link"
300
- className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
301
- asChild
302
- >
303
- <a
304
- href={`mailto:${value}`}
305
- onClick={(e) => e.stopPropagation()}
466
+ <span className="inline-flex items-center gap-1 group/email">
467
+ <Button
468
+ variant="link"
469
+ className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
470
+ asChild
306
471
  >
307
- {value}
308
- </a>
309
- </Button>
472
+ <a
473
+ href={`mailto:${value}`}
474
+ onClick={(e) => e.stopPropagation()}
475
+ >
476
+ {value}
477
+ </a>
478
+ </Button>
479
+ <button
480
+ type="button"
481
+ className="opacity-0 group-hover/email:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
482
+ onClick={handleCopy}
483
+ aria-label="Copy email"
484
+ >
485
+ {copied ? (
486
+ <Check className="h-3 w-3 text-green-600" />
487
+ ) : (
488
+ <Copy className="h-3 w-3 text-muted-foreground" />
489
+ )}
490
+ </button>
491
+ </span>
310
492
  );
311
493
  }
312
494
 
@@ -340,14 +522,40 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
340
522
  export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
341
523
  if (!value) return <span>-</span>;
342
524
 
525
+ const [copied, setCopied] = React.useState(false);
526
+
527
+ const handleCopy = (e: React.MouseEvent) => {
528
+ e.stopPropagation();
529
+ e.preventDefault();
530
+ navigator.clipboard.writeText(String(value)).then(() => {
531
+ setCopied(true);
532
+ setTimeout(() => setCopied(false), 2000);
533
+ }).catch(() => { /* clipboard not available */ });
534
+ };
535
+
343
536
  return (
344
- <a
345
- href={`tel:${value}`}
346
- className="text-blue-600 hover:text-blue-800"
347
- onClick={(e) => e.stopPropagation()}
348
- >
349
- {value}
350
- </a>
537
+ <span className="inline-flex items-center gap-1 group/phone">
538
+ <a
539
+ href={`tel:${value}`}
540
+ className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
541
+ onClick={(e) => e.stopPropagation()}
542
+ >
543
+ <PhoneIcon className="h-3 w-3" />
544
+ {value}
545
+ </a>
546
+ <button
547
+ type="button"
548
+ className="opacity-0 group-hover/phone:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
549
+ onClick={handleCopy}
550
+ aria-label="Copy phone number"
551
+ >
552
+ {copied ? (
553
+ <Check className="h-3 w-3 text-green-600" />
554
+ ) : (
555
+ <Copy className="h-3 w-3 text-muted-foreground" />
556
+ )}
557
+ </button>
558
+ </span>
351
559
  );
352
560
  }
353
561
 
@@ -411,14 +619,31 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
411
619
  /**
412
620
  * Lookup/Master-Detail field cell renderer
413
621
  */
414
- export function LookupCellRenderer({ value }: CellRendererProps): React.ReactElement {
415
- if (!value) return <span>-</span>;
416
-
622
+ export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
623
+ if (value == null || value === '') return <span>-</span>;
624
+
625
+ const options: Array<{ value: unknown; label: string }> =
626
+ (field as { options?: Array<{ value: unknown; label: string }> }).options || [];
627
+
628
+ // Resolve a primitive ID to a label via options if available
629
+ const resolveLabel = (val: unknown): string => {
630
+ if (options.length > 0) {
631
+ const found = options.find((opt) => String(opt.value) === String(val));
632
+ if (found) return found.label;
633
+ }
634
+ return String(val);
635
+ };
636
+
417
637
  if (Array.isArray(value)) {
418
638
  return (
419
639
  <div className="flex flex-wrap gap-1">
420
640
  {value.map((item, idx) => {
421
- const label = item.name || item.label || item._id || String(item);
641
+ let label: string;
642
+ if (item != null && typeof item === 'object') {
643
+ label = item.name || item.label || item._id || String(item);
644
+ } else {
645
+ label = resolveLabel(item);
646
+ }
422
647
  return (
423
648
  <span
424
649
  key={idx}
@@ -437,7 +662,8 @@ export function LookupCellRenderer({ value }: CellRendererProps): React.ReactEle
437
662
  return <span className="truncate">{label}</span>;
438
663
  }
439
664
 
440
- return <span className="truncate">{String(value)}</span>;
665
+ // Primitive value (e.g. raw ID): try to resolve from options
666
+ return <span className="truncate">{resolveLabel(value)}</span>;
441
667
  }
442
668
 
443
669
  /**
@@ -456,11 +682,24 @@ export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactEl
456
682
  */
457
683
  export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
458
684
  if (!value) return <span>-</span>;
685
+
686
+ // Primitive value: just display the ID/username as text
687
+ if (typeof value !== 'object') {
688
+ return <span className="truncate">{String(value)}</span>;
689
+ }
459
690
 
460
691
  if (Array.isArray(value)) {
461
692
  return (
462
693
  <div className="flex -space-x-2">
463
694
  {value.slice(0, 3).map((user, idx) => {
695
+ // Primitive user in array
696
+ if (typeof user !== 'object' || user === null) {
697
+ return (
698
+ <span key={idx} className="truncate text-sm">
699
+ {String(user)}
700
+ </span>
701
+ );
702
+ }
464
703
  const name = user.name || user.username || 'User';
465
704
  const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
466
705
 
@@ -540,8 +779,9 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
540
779
  datetime: DateTimeCellRenderer,
541
780
  time: TextCellRenderer,
542
781
  select: SelectCellRenderer,
543
- lookup: SelectCellRenderer, // Default fallback
544
- master_detail: SelectCellRenderer, // Default fallback
782
+ status: SelectCellRenderer,
783
+ lookup: LookupCellRenderer,
784
+ master_detail: LookupCellRenderer,
545
785
  email: EmailCellRenderer,
546
786
  url: UrlCellRenderer,
547
787
  phone: PhoneCellRenderer,
@@ -569,6 +809,9 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
569
809
  registerFieldRenderer('lookup', LookupCellRenderer);
570
810
  registerFieldRenderer('master_detail', LookupCellRenderer);
571
811
  registerFieldRenderer('select', SelectCellRenderer);
812
+ registerFieldRenderer('status', SelectCellRenderer);
813
+ registerFieldRenderer('user', UserCellRenderer);
814
+ registerFieldRenderer('owner', UserCellRenderer);
572
815
 
573
816
 
574
817
 
@@ -92,6 +92,32 @@ describe('Remaining Field Widgets', () => {
92
92
  fireEvent.change(input, { target: { value: '75' } });
93
93
  expect(handleChange).toHaveBeenCalledWith(0.75);
94
94
  });
95
+
96
+ it('slider renders and syncs with input value', () => {
97
+ render(<PercentField {...baseProps} value={0.5} />);
98
+ const slider = screen.getByTestId('percent-slider');
99
+ expect(slider).toBeInTheDocument();
100
+ });
101
+
102
+ it('slider does not fire onChange when disabled', () => {
103
+ const handleChange = vi.fn();
104
+ render(<PercentField {...baseProps} onChange={handleChange} value={0.5} disabled={true} />);
105
+ const slider = screen.getByTestId('percent-slider');
106
+ expect(slider).toBeInTheDocument();
107
+ // The slider should be disabled, so no changes should fire
108
+ expect(handleChange).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it('slider is not rendered in readonly mode', () => {
112
+ render(<PercentField {...baseProps} value={0.5} readonly={true} />);
113
+ expect(screen.queryByTestId('percent-slider')).not.toBeInTheDocument();
114
+ });
115
+
116
+ it('handles null value gracefully for slider', () => {
117
+ render(<PercentField {...baseProps} value={null as any} />);
118
+ const slider = screen.getByTestId('percent-slider');
119
+ expect(slider).toBeInTheDocument();
120
+ });
95
121
  });
96
122
 
97
123
  // 5. ImageField