@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.
- package/dist/fields/src/__tests__/boolean-checkbox.test.d.ts +0 -0
- package/dist/fields/src/__tests__/cell-renderers.test.d.ts +0 -0
- package/dist/fields/src/__tests__/datetime-cell.test.d.ts +0 -0
- package/dist/fields/src/index.d.ts +26 -5
- package/dist/fields/src/widgets/FileField.d.ts +2 -1
- package/dist/fields/src/widgets/PercentField.d.ts +1 -0
- package/dist/index.cjs +8 -3
- package/dist/index.js +1370 -1144
- package/package.json +7 -7
- package/src/__tests__/boolean-checkbox.test.tsx +74 -0
- package/src/__tests__/cell-renderers.test.tsx +1043 -0
- package/src/__tests__/datetime-cell.test.tsx +46 -0
- package/src/index.tsx +345 -75
- package/src/remaining-widgets.test.tsx +26 -0
- package/src/widgets/FileField.tsx +137 -35
- package/src/widgets/GridField.tsx +9 -9
- package/src/widgets/PercentField.tsx +43 -14
|
@@ -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,
|
|
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:
|
|
96
|
-
return date.toLocaleDateString(
|
|
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(
|
|
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
|
|
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
|
-
|
|
165
|
-
//
|
|
166
|
-
const
|
|
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
|
|
323
|
+
const style = dateField.format || 'relative';
|
|
324
|
+
const formatted = formatDate(value, style);
|
|
215
325
|
|
|
216
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
return <span className="
|
|
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 =
|
|
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 =
|
|
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
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
<
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
|
|
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
|