@object-ui/fields 3.0.3 → 3.1.1

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,111 @@ 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
+ // Semantic color mapping (auto-detect from value text for priority & status fields)
372
+ // Keys use underscore notation; lookup normalizes spaces/hyphens to underscores automatically.
373
+ const SEMANTIC_COLOR_MAP: Record<string, string> = {
374
+ // Priority values
375
+ critical: 'red',
376
+ urgent: 'red',
377
+ high: 'orange',
378
+ medium: 'yellow',
379
+ normal: 'blue',
380
+ low: 'gray',
381
+ none: 'gray',
382
+ // Status values
383
+ paid: 'green',
384
+ completed: 'green',
385
+ done: 'green',
386
+ active: 'green',
387
+ approved: 'green',
388
+ resolved: 'green',
389
+ pending: 'yellow',
390
+ waiting: 'yellow',
391
+ on_hold: 'yellow',
392
+ shipped: 'blue',
393
+ in_progress: 'blue',
394
+ open: 'blue',
395
+ processing: 'blue',
396
+ draft: 'gray',
397
+ new: 'gray',
398
+ inactive: 'gray',
399
+ closed: 'gray',
400
+ cancelled: 'red',
401
+ canceled: 'red',
402
+ rejected: 'red',
403
+ failed: 'red',
404
+ overdue: 'red',
405
+ delivered: 'purple',
406
+ archived: 'indigo',
407
+ };
408
+
409
+ // Color to Tailwind class mapping for custom Badge styling
410
+ const BADGE_COLOR_MAP: Record<string, string> = {
411
+ gray: 'bg-gray-100 text-gray-800 border-gray-300',
412
+ red: 'bg-red-100 text-red-800 border-red-300',
413
+ orange: 'bg-orange-100 text-orange-800 border-orange-300',
414
+ yellow: 'bg-yellow-100 text-yellow-800 border-yellow-300',
415
+ green: 'bg-green-100 text-green-800 border-green-300',
416
+ blue: 'bg-blue-100 text-blue-800 border-blue-300',
417
+ indigo: 'bg-indigo-100 text-indigo-800 border-indigo-300',
418
+ purple: 'bg-purple-100 text-purple-800 border-purple-300',
419
+ pink: 'bg-pink-100 text-pink-800 border-pink-300',
420
+ };
421
+
422
+ function getBadgeColorClasses(color?: string, val?: string): string {
423
+ const resolvedColor = color
424
+ || (val ? SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')] : undefined);
425
+ return BADGE_COLOR_MAP[resolvedColor || ''] || 'bg-muted text-muted-foreground border-border';
226
426
  }
227
427
 
228
428
  /**
@@ -234,30 +434,14 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
234
434
 
235
435
  if (!value) return <span>-</span>;
236
436
 
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
437
  // Handle multiple values
254
438
  if (Array.isArray(value)) {
255
439
  return (
256
440
  <div className="flex flex-wrap gap-1">
257
441
  {value.map((val, idx) => {
258
442
  const option = options.find(opt => opt.value === val);
259
- const label = option?.label || val;
260
- const colorClasses = getColorClasses(option?.color);
443
+ const label = option?.label || humanizeLabel(String(val));
444
+ const colorClasses = getBadgeColorClasses(option?.color, val);
261
445
 
262
446
  return (
263
447
  <Badge
@@ -275,8 +459,8 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
275
459
 
276
460
  // Handle single value
277
461
  const option = options.find(opt => opt.value === value);
278
- const label = option?.label || value;
279
- const colorClasses = getColorClasses(option?.color);
462
+ const label = option?.label || humanizeLabel(String(value));
463
+ const colorClasses = getBadgeColorClasses(option?.color, value);
280
464
 
281
465
  return (
282
466
  <Badge
@@ -294,19 +478,44 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
294
478
  export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
295
479
  if (!value) return <span>-</span>;
296
480
 
481
+ const [copied, setCopied] = React.useState(false);
482
+
483
+ const handleCopy = (e: React.MouseEvent) => {
484
+ e.stopPropagation();
485
+ e.preventDefault();
486
+ navigator.clipboard.writeText(String(value)).then(() => {
487
+ setCopied(true);
488
+ setTimeout(() => setCopied(false), 2000);
489
+ }).catch(() => { /* clipboard not available */ });
490
+ };
491
+
297
492
  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()}
493
+ <span className="inline-flex items-center gap-1 group/email">
494
+ <Button
495
+ variant="link"
496
+ className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
497
+ asChild
306
498
  >
307
- {value}
308
- </a>
309
- </Button>
499
+ <a
500
+ href={`mailto:${value}`}
501
+ onClick={(e) => e.stopPropagation()}
502
+ >
503
+ {value}
504
+ </a>
505
+ </Button>
506
+ <button
507
+ type="button"
508
+ className="opacity-0 group-hover/email:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
509
+ onClick={handleCopy}
510
+ aria-label="Copy email"
511
+ >
512
+ {copied ? (
513
+ <Check className="h-3 w-3 text-green-600" />
514
+ ) : (
515
+ <Copy className="h-3 w-3 text-muted-foreground" />
516
+ )}
517
+ </button>
518
+ </span>
310
519
  );
311
520
  }
312
521
 
@@ -340,14 +549,40 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
340
549
  export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
341
550
  if (!value) return <span>-</span>;
342
551
 
552
+ const [copied, setCopied] = React.useState(false);
553
+
554
+ const handleCopy = (e: React.MouseEvent) => {
555
+ e.stopPropagation();
556
+ e.preventDefault();
557
+ navigator.clipboard.writeText(String(value)).then(() => {
558
+ setCopied(true);
559
+ setTimeout(() => setCopied(false), 2000);
560
+ }).catch(() => { /* clipboard not available */ });
561
+ };
562
+
343
563
  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>
564
+ <span className="inline-flex items-center gap-1 group/phone">
565
+ <a
566
+ href={`tel:${value}`}
567
+ className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
568
+ onClick={(e) => e.stopPropagation()}
569
+ >
570
+ <PhoneIcon className="h-3 w-3" />
571
+ {value}
572
+ </a>
573
+ <button
574
+ type="button"
575
+ className="opacity-0 group-hover/phone:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
576
+ onClick={handleCopy}
577
+ aria-label="Copy phone number"
578
+ >
579
+ {copied ? (
580
+ <Check className="h-3 w-3 text-green-600" />
581
+ ) : (
582
+ <Copy className="h-3 w-3 text-muted-foreground" />
583
+ )}
584
+ </button>
585
+ </span>
351
586
  );
352
587
  }
353
588
 
@@ -411,14 +646,31 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
411
646
  /**
412
647
  * Lookup/Master-Detail field cell renderer
413
648
  */
414
- export function LookupCellRenderer({ value }: CellRendererProps): React.ReactElement {
415
- if (!value) return <span>-</span>;
416
-
649
+ export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
650
+ if (value == null || value === '') return <span>-</span>;
651
+
652
+ const options: Array<{ value: unknown; label: string }> =
653
+ (field as { options?: Array<{ value: unknown; label: string }> }).options || [];
654
+
655
+ // Resolve a primitive ID to a label via options if available
656
+ const resolveLabel = (val: unknown): string => {
657
+ if (options.length > 0) {
658
+ const found = options.find((opt) => String(opt.value) === String(val));
659
+ if (found) return found.label;
660
+ }
661
+ return String(val);
662
+ };
663
+
417
664
  if (Array.isArray(value)) {
418
665
  return (
419
666
  <div className="flex flex-wrap gap-1">
420
667
  {value.map((item, idx) => {
421
- const label = item.name || item.label || item._id || String(item);
668
+ let label: string;
669
+ if (item != null && typeof item === 'object') {
670
+ label = item.name || item.label || item._id || String(item);
671
+ } else {
672
+ label = resolveLabel(item);
673
+ }
422
674
  return (
423
675
  <span
424
676
  key={idx}
@@ -437,7 +689,8 @@ export function LookupCellRenderer({ value }: CellRendererProps): React.ReactEle
437
689
  return <span className="truncate">{label}</span>;
438
690
  }
439
691
 
440
- return <span className="truncate">{String(value)}</span>;
692
+ // Primitive value (e.g. raw ID): try to resolve from options
693
+ return <span className="truncate">{resolveLabel(value)}</span>;
441
694
  }
442
695
 
443
696
  /**
@@ -456,11 +709,24 @@ export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactEl
456
709
  */
457
710
  export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
458
711
  if (!value) return <span>-</span>;
712
+
713
+ // Primitive value: just display the ID/username as text
714
+ if (typeof value !== 'object') {
715
+ return <span className="truncate">{String(value)}</span>;
716
+ }
459
717
 
460
718
  if (Array.isArray(value)) {
461
719
  return (
462
720
  <div className="flex -space-x-2">
463
721
  {value.slice(0, 3).map((user, idx) => {
722
+ // Primitive user in array
723
+ if (typeof user !== 'object' || user === null) {
724
+ return (
725
+ <span key={idx} className="truncate text-sm">
726
+ {String(user)}
727
+ </span>
728
+ );
729
+ }
464
730
  const name = user.name || user.username || 'User';
465
731
  const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
466
732
 
@@ -540,8 +806,9 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
540
806
  datetime: DateTimeCellRenderer,
541
807
  time: TextCellRenderer,
542
808
  select: SelectCellRenderer,
543
- lookup: SelectCellRenderer, // Default fallback
544
- master_detail: SelectCellRenderer, // Default fallback
809
+ status: SelectCellRenderer,
810
+ lookup: LookupCellRenderer,
811
+ master_detail: LookupCellRenderer,
545
812
  email: EmailCellRenderer,
546
813
  url: UrlCellRenderer,
547
814
  phone: PhoneCellRenderer,
@@ -569,6 +836,9 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
569
836
  registerFieldRenderer('lookup', LookupCellRenderer);
570
837
  registerFieldRenderer('master_detail', LookupCellRenderer);
571
838
  registerFieldRenderer('select', SelectCellRenderer);
839
+ registerFieldRenderer('status', SelectCellRenderer);
840
+ registerFieldRenderer('user', UserCellRenderer);
841
+ registerFieldRenderer('owner', UserCellRenderer);
572
842
 
573
843
 
574
844
 
@@ -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