@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.
- 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 +1308 -1108
- package/package.json +7 -7
- package/src/__tests__/boolean-checkbox.test.tsx +74 -0
- package/src/__tests__/cell-renderers.test.tsx +953 -0
- package/src/__tests__/datetime-cell.test.tsx +46 -0
- package/src/index.tsx +318 -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,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
|
|
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
|
+
// 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 =
|
|
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 =
|
|
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
|
-
<
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
<
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
544
|
-
|
|
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
|