@object-ui/fields 4.0.7 → 4.0.8
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/CHANGELOG.md +12 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +1220 -1038
- package/dist/packages/fields/src/index.d.ts +19 -3
- package/dist/packages/fields/src/widgets/RecordPickerDialog.d.ts +9 -1
- package/package.json +9 -9
- package/src/index.tsx +176 -33
- package/src/widgets/CurrencyField.tsx +33 -11
- package/src/widgets/LookupField.tsx +132 -9
- package/src/widgets/RecordPickerDialog.tsx +37 -1
|
@@ -25,6 +25,12 @@ export declare function formatCurrency(value: number, currency?: string): string
|
|
|
25
25
|
* E.g., $150,000 → $150K, $1,200,000 → $1.2M
|
|
26
26
|
*/
|
|
27
27
|
export declare function formatCompactCurrency(value: number, currency?: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Format a plain number with thousands separators, no currency symbol.
|
|
30
|
+
* Used as a safe fallback when a currency-typed field has no `currency`
|
|
31
|
+
* configured — we'd rather render `1,234.50` than silently assume USD.
|
|
32
|
+
*/
|
|
33
|
+
export declare function formatNumber(value: number, decimals?: number): string;
|
|
28
34
|
/**
|
|
29
35
|
* Format percent value
|
|
30
36
|
* Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
|
|
@@ -43,15 +49,15 @@ export declare function humanizeLabel(value: string): string;
|
|
|
43
49
|
/**
|
|
44
50
|
* Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
|
|
45
51
|
*/
|
|
46
|
-
export declare function formatRelativeDate(value: string | Date): string;
|
|
52
|
+
export declare function formatRelativeDate(value: string | Date | number): string;
|
|
47
53
|
/**
|
|
48
54
|
* Format date value
|
|
49
55
|
*/
|
|
50
|
-
export declare function formatDate(value: string | Date, style?: string): string;
|
|
56
|
+
export declare function formatDate(value: string | Date | number, style?: string): string;
|
|
51
57
|
/**
|
|
52
58
|
* Format datetime value
|
|
53
59
|
*/
|
|
54
|
-
export declare function formatDateTime(value: string | Date): string;
|
|
60
|
+
export declare function formatDateTime(value: string | Date | number): string;
|
|
55
61
|
/**
|
|
56
62
|
* Text field cell renderer
|
|
57
63
|
*/
|
|
@@ -124,6 +130,16 @@ export declare function UserCellRenderer({ value }: CellRendererProps): React.Re
|
|
|
124
130
|
* @param renderer React component to render the field
|
|
125
131
|
*/
|
|
126
132
|
export declare function registerFieldRenderer(type: string, renderer: React.FC<CellRendererProps>): void;
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the canonical cell-renderer key for a field. Accepts either a
|
|
135
|
+
* raw type string (back-compat) or a field-metadata object so that
|
|
136
|
+
* format hints (`format: 'phone'` etc.) can promote a plain `text`
|
|
137
|
+
* field to its richer renderer counterpart.
|
|
138
|
+
*/
|
|
139
|
+
export declare function resolveCellRendererType(fieldOrType: string | {
|
|
140
|
+
type?: string;
|
|
141
|
+
format?: string;
|
|
142
|
+
} | null | undefined): string;
|
|
127
143
|
/**
|
|
128
144
|
* Get the appropriate cell renderer for a field type
|
|
129
145
|
*/
|
|
@@ -91,6 +91,14 @@ export interface RecordPickerDialogProps {
|
|
|
91
91
|
columns?: Array<string | LookupColumnDef>;
|
|
92
92
|
/** Primary display field (default: 'name') */
|
|
93
93
|
displayField?: string;
|
|
94
|
+
/**
|
|
95
|
+
* Optional `titleFormat` template (e.g. `"{full_name}"` or
|
|
96
|
+
* `"{case_number} - {subject}"`). When set and the displayField column is
|
|
97
|
+
* auto-inferred, the column renders via the template instead of reading
|
|
98
|
+
* a possibly-missing field. Mirrors how DetailView/ObjectCalendar resolve
|
|
99
|
+
* record titles.
|
|
100
|
+
*/
|
|
101
|
+
titleFormat?: string | null;
|
|
94
102
|
/** Record id field (default: 'id') */
|
|
95
103
|
idField?: string;
|
|
96
104
|
/** Page size (default: 10) */
|
|
@@ -163,4 +171,4 @@ export interface RecordPickerDialogProps {
|
|
|
163
171
|
* states, and single/multi-select. Responsive: mobile-friendly width
|
|
164
172
|
* via Tailwind breakpoints.
|
|
165
173
|
*/
|
|
166
|
-
export declare function RecordPickerDialog({ open, onOpenChange, title, multiple, dataSource, objectName, columns: columnsProp, displayField, idField, pageSize, value, onSelect, onSelectRecords, lookupFilters, cellRenderer, filterColumns, renderFilterBar, renderGrid, }: RecordPickerDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
174
|
+
export declare function RecordPickerDialog({ open, onOpenChange, title, multiple, dataSource, objectName, columns: columnsProp, displayField, titleFormat, idField, pageSize, value, onSelect, onSelectRecords, lookupFilters, cellRenderer, filterColumns, renderFilterBar, renderGrid, }: RecordPickerDialogProps): import("react/jsx-runtime").JSX.Element;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.8",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,13 +26,13 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"clsx": "^2.1.1",
|
|
28
28
|
"lucide-react": "^1.14.0",
|
|
29
|
-
"tailwind-merge": "^3.
|
|
30
|
-
"@object-ui/components": "4.0.
|
|
31
|
-
"@object-ui/core": "4.0.
|
|
32
|
-
"@object-ui/i18n": "4.0.
|
|
33
|
-
"@object-ui/providers": "4.0.
|
|
34
|
-
"@object-ui/react": "4.0.
|
|
35
|
-
"@object-ui/types": "4.0.
|
|
29
|
+
"tailwind-merge": "^3.6.0",
|
|
30
|
+
"@object-ui/components": "4.0.8",
|
|
31
|
+
"@object-ui/core": "4.0.8",
|
|
32
|
+
"@object-ui/i18n": "4.0.8",
|
|
33
|
+
"@object-ui/providers": "4.0.8",
|
|
34
|
+
"@object-ui/react": "4.0.8",
|
|
35
|
+
"@object-ui/types": "4.0.8"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"@types/react-dom": "19.2.3",
|
|
44
44
|
"@vitejs/plugin-react": "^6.0.1",
|
|
45
45
|
"typescript": "^6.0.3",
|
|
46
|
-
"vite": "^8.0.
|
|
46
|
+
"vite": "^8.0.12",
|
|
47
47
|
"vite-plugin-dts": "^5.0.0"
|
|
48
48
|
},
|
|
49
49
|
"publishConfig": {
|
package/src/index.tsx
CHANGED
|
@@ -11,6 +11,23 @@ import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
|
|
|
11
11
|
import { ComponentRegistry } from '@object-ui/core';
|
|
12
12
|
import { Badge, Avatar, AvatarFallback, Button, Checkbox, EmptyValue } from '@object-ui/components';
|
|
13
13
|
import { Check, X, Copy, Phone as PhoneIcon } from 'lucide-react';
|
|
14
|
+
import { useObjectTranslation } from '@object-ui/react';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Safe label resolver for cell-level UI strings. Falls back to the English
|
|
18
|
+
* default when no I18nProvider is available or when the key is missing.
|
|
19
|
+
*/
|
|
20
|
+
function useFieldLabel() {
|
|
21
|
+
try {
|
|
22
|
+
const { t } = useObjectTranslation();
|
|
23
|
+
return (key: string, fallback: string) => {
|
|
24
|
+
const v = t(key);
|
|
25
|
+
return !v || v === key ? fallback : v;
|
|
26
|
+
};
|
|
27
|
+
} catch {
|
|
28
|
+
return (_k: string, fallback: string) => fallback;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
14
31
|
|
|
15
32
|
import { TextField } from './widgets/TextField';
|
|
16
33
|
import { NumberField } from './widgets/NumberField';
|
|
@@ -127,6 +144,22 @@ export function formatCompactCurrency(value: number, currency: string = 'USD'):
|
|
|
127
144
|
}
|
|
128
145
|
}
|
|
129
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Format a plain number with thousands separators, no currency symbol.
|
|
149
|
+
* Used as a safe fallback when a currency-typed field has no `currency`
|
|
150
|
+
* configured — we'd rather render `1,234.50` than silently assume USD.
|
|
151
|
+
*/
|
|
152
|
+
export function formatNumber(value: number, decimals: number = 2): string {
|
|
153
|
+
try {
|
|
154
|
+
return new Intl.NumberFormat('en-US', {
|
|
155
|
+
minimumFractionDigits: decimals,
|
|
156
|
+
maximumFractionDigits: decimals,
|
|
157
|
+
}).format(value);
|
|
158
|
+
} catch {
|
|
159
|
+
return value.toFixed(decimals);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
130
163
|
/**
|
|
131
164
|
* Format percent value
|
|
132
165
|
* Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
|
|
@@ -153,10 +186,10 @@ export function humanizeLabel(value: string): string {
|
|
|
153
186
|
/**
|
|
154
187
|
* Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
|
|
155
188
|
*/
|
|
156
|
-
export function formatRelativeDate(value: string | Date): string {
|
|
157
|
-
if (
|
|
158
|
-
const date =
|
|
159
|
-
if (isNaN(date.getTime())) return '—';
|
|
189
|
+
export function formatRelativeDate(value: string | Date | number): string {
|
|
190
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
191
|
+
const date = value instanceof Date ? value : new Date(value as any);
|
|
192
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
160
193
|
|
|
161
194
|
const now = new Date();
|
|
162
195
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
@@ -179,10 +212,10 @@ export function formatRelativeDate(value: string | Date): string {
|
|
|
179
212
|
/**
|
|
180
213
|
* Format date value
|
|
181
214
|
*/
|
|
182
|
-
export function formatDate(value: string | Date, style?: string): string {
|
|
183
|
-
if (
|
|
184
|
-
const date =
|
|
185
|
-
if (isNaN(date.getTime())) return '—';
|
|
215
|
+
export function formatDate(value: string | Date | number, style?: string): string {
|
|
216
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
217
|
+
const date = value instanceof Date ? value : new Date(value as any);
|
|
218
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
186
219
|
|
|
187
220
|
if (style === 'short') {
|
|
188
221
|
// Compact format for mobile: "Jan 15, '24"
|
|
@@ -207,10 +240,10 @@ export function formatDate(value: string | Date, style?: string): string {
|
|
|
207
240
|
/**
|
|
208
241
|
* Format datetime value
|
|
209
242
|
*/
|
|
210
|
-
export function formatDateTime(value: string | Date): string {
|
|
211
|
-
if (
|
|
212
|
-
const date =
|
|
213
|
-
if (isNaN(date.getTime())) return '—';
|
|
243
|
+
export function formatDateTime(value: string | Date | number): string {
|
|
244
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
245
|
+
const date = value instanceof Date ? value : new Date(value as any);
|
|
246
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
214
247
|
|
|
215
248
|
return date.toLocaleDateString(undefined, {
|
|
216
249
|
year: 'numeric',
|
|
@@ -255,9 +288,14 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
|
|
|
255
288
|
|
|
256
289
|
const safe = coerceToSafeValue(value);
|
|
257
290
|
const currencyField = field as any;
|
|
258
|
-
|
|
291
|
+
// Honor both `currency` (legacy/grid configs) and `defaultCurrency`
|
|
292
|
+
// (the canonical key from `@objectstack/spec` Field.currency()).
|
|
293
|
+
const currency: string | undefined = currencyField.currency || currencyField.defaultCurrency;
|
|
259
294
|
const num = Number(safe);
|
|
260
|
-
const
|
|
295
|
+
const decimals = currencyField.precision ?? currencyField.scale ?? currencyField.decimals ?? 2;
|
|
296
|
+
const formatted = !isNaN(num)
|
|
297
|
+
? (currency ? formatCurrency(num, currency) : formatNumber(num, decimals))
|
|
298
|
+
: String(safe);
|
|
261
299
|
|
|
262
300
|
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
|
|
263
301
|
}
|
|
@@ -351,7 +389,7 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
351
389
|
}
|
|
352
390
|
|
|
353
391
|
return (
|
|
354
|
-
<div className="flex items-center justify-
|
|
392
|
+
<div className="flex items-center justify-start">
|
|
355
393
|
<Checkbox checked={!!value} disabled className="pointer-events-none" />
|
|
356
394
|
</div>
|
|
357
395
|
);
|
|
@@ -370,16 +408,16 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
370
408
|
// Determine if date is overdue (in the past) — but only color it red when the
|
|
371
409
|
// field is *semantically* a due/deadline. A plain "start_date" or "created_at"
|
|
372
410
|
// in the past should not render in red.
|
|
373
|
-
const date =
|
|
374
|
-
const isValidDate = date
|
|
411
|
+
const date = safe != null ? new Date(safe as string | number) : null;
|
|
412
|
+
const isValidDate = date !== null && !isNaN(date.getTime());
|
|
375
413
|
const startOfToday = new Date();
|
|
376
414
|
startOfToday.setHours(0, 0, 0, 0);
|
|
377
415
|
const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
|
|
378
416
|
const dueLike =
|
|
379
417
|
dateField?.dueLike === true ||
|
|
380
418
|
/(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName);
|
|
381
|
-
const isOverdue = dueLike && isValidDate && date < startOfToday;
|
|
382
|
-
const isoString = isValidDate ? date
|
|
419
|
+
const isOverdue = dueLike && isValidDate && date! < startOfToday;
|
|
420
|
+
const isoString = isValidDate ? date!.toISOString() : String(safe);
|
|
383
421
|
|
|
384
422
|
return (
|
|
385
423
|
<span
|
|
@@ -397,8 +435,8 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
397
435
|
export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
398
436
|
if (!value) return <EmptyValue />;
|
|
399
437
|
const safe = coerceToSafeValue(value);
|
|
400
|
-
const date =
|
|
401
|
-
if (
|
|
438
|
+
const date = safe != null ? new Date(safe as string | number) : null;
|
|
439
|
+
if (date === null || isNaN(date.getTime())) return <EmptyValue />;
|
|
402
440
|
|
|
403
441
|
const datePart = date.toLocaleDateString(undefined, {
|
|
404
442
|
month: 'numeric',
|
|
@@ -421,8 +459,9 @@ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactE
|
|
|
421
459
|
|
|
422
460
|
// Semantic color mapping (auto-detect from value text for priority & status fields)
|
|
423
461
|
// Keys use underscore notation; lookup normalizes spaces/hyphens to underscores automatically.
|
|
462
|
+
// Chinese keys are stored as-is and matched directly (no normalization side-effects).
|
|
424
463
|
const SEMANTIC_COLOR_MAP: Record<string, string> = {
|
|
425
|
-
// Priority values
|
|
464
|
+
// Priority values (en)
|
|
426
465
|
critical: 'red',
|
|
427
466
|
urgent: 'red',
|
|
428
467
|
high: 'orange',
|
|
@@ -430,7 +469,7 @@ const SEMANTIC_COLOR_MAP: Record<string, string> = {
|
|
|
430
469
|
normal: 'blue',
|
|
431
470
|
low: 'gray',
|
|
432
471
|
none: 'gray',
|
|
433
|
-
// Status values
|
|
472
|
+
// Status values (en)
|
|
434
473
|
paid: 'green',
|
|
435
474
|
completed: 'green',
|
|
436
475
|
done: 'green',
|
|
@@ -455,6 +494,52 @@ const SEMANTIC_COLOR_MAP: Record<string, string> = {
|
|
|
455
494
|
overdue: 'red',
|
|
456
495
|
delivered: 'purple',
|
|
457
496
|
archived: 'indigo',
|
|
497
|
+
// CRM lifecycle values (en)
|
|
498
|
+
contacted: 'blue',
|
|
499
|
+
qualified: 'purple',
|
|
500
|
+
converted: 'green',
|
|
501
|
+
won: 'green',
|
|
502
|
+
lost: 'red',
|
|
503
|
+
// Priority values (zh)
|
|
504
|
+
紧急: 'red',
|
|
505
|
+
严重: 'red',
|
|
506
|
+
高: 'orange',
|
|
507
|
+
中: 'yellow',
|
|
508
|
+
普通: 'blue',
|
|
509
|
+
低: 'gray',
|
|
510
|
+
无: 'gray',
|
|
511
|
+
// Status values (zh)
|
|
512
|
+
新建: 'gray',
|
|
513
|
+
草稿: 'gray',
|
|
514
|
+
待处理: 'yellow',
|
|
515
|
+
待审核: 'yellow',
|
|
516
|
+
待联系: 'yellow',
|
|
517
|
+
挂起: 'yellow',
|
|
518
|
+
进行中: 'blue',
|
|
519
|
+
处理中: 'blue',
|
|
520
|
+
已联系: 'blue',
|
|
521
|
+
跟进中: 'blue',
|
|
522
|
+
已发货: 'blue',
|
|
523
|
+
打开: 'blue',
|
|
524
|
+
已确认: 'green',
|
|
525
|
+
已审核: 'green',
|
|
526
|
+
已通过: 'green',
|
|
527
|
+
已完成: 'green',
|
|
528
|
+
已支付: 'green',
|
|
529
|
+
已签收: 'green',
|
|
530
|
+
已转化: 'green',
|
|
531
|
+
成单: 'green',
|
|
532
|
+
赢得: 'green',
|
|
533
|
+
已签约: 'green',
|
|
534
|
+
已交付: 'purple',
|
|
535
|
+
已归档: 'indigo',
|
|
536
|
+
已关闭: 'gray',
|
|
537
|
+
已取消: 'red',
|
|
538
|
+
已拒绝: 'red',
|
|
539
|
+
失败: 'red',
|
|
540
|
+
逾期: 'red',
|
|
541
|
+
流失: 'red',
|
|
542
|
+
丢失: 'red',
|
|
458
543
|
};
|
|
459
544
|
|
|
460
545
|
// Color to Tailwind class mapping for custom Badge styling
|
|
@@ -511,18 +596,29 @@ function getBadgeColorClasses(color?: string, val?: unknown): string {
|
|
|
511
596
|
export function SelectCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
512
597
|
const selectField = field as any;
|
|
513
598
|
const options: SelectOptionMetadata[] = selectField.options || [];
|
|
514
|
-
|
|
599
|
+
|
|
515
600
|
if (value == null || value === '') return <EmptyValue />;
|
|
516
|
-
|
|
601
|
+
|
|
602
|
+
// Match a stored value to a configured option, falling back to a
|
|
603
|
+
// case-insensitive comparison so seed data with mixed case
|
|
604
|
+
// (e.g. "Referral" stored, "referral" defined) still resolves to the
|
|
605
|
+
// localized option label.
|
|
606
|
+
const findOption = (val: any): SelectOptionMetadata | undefined => {
|
|
607
|
+
const exact = options.find(opt => opt.value === val);
|
|
608
|
+
if (exact) return exact;
|
|
609
|
+
const norm = String(val).toLowerCase();
|
|
610
|
+
return options.find(opt => String(opt.value).toLowerCase() === norm);
|
|
611
|
+
};
|
|
612
|
+
|
|
517
613
|
// Handle multiple values
|
|
518
614
|
if (Array.isArray(value)) {
|
|
519
615
|
return (
|
|
520
616
|
<div className="flex flex-wrap gap-1">
|
|
521
617
|
{value.map((val, idx) => {
|
|
522
|
-
const option =
|
|
618
|
+
const option = findOption(val);
|
|
523
619
|
const label = option?.label || humanizeLabel(String(val));
|
|
524
620
|
const colorClasses = getBadgeColorClasses(option?.color, val);
|
|
525
|
-
|
|
621
|
+
|
|
526
622
|
return (
|
|
527
623
|
<Badge
|
|
528
624
|
key={idx}
|
|
@@ -536,9 +632,9 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
536
632
|
</div>
|
|
537
633
|
);
|
|
538
634
|
}
|
|
539
|
-
|
|
635
|
+
|
|
540
636
|
// Handle single value
|
|
541
|
-
const option =
|
|
637
|
+
const option = findOption(value);
|
|
542
638
|
const label = option?.label || humanizeLabel(String(value));
|
|
543
639
|
const colorClasses = getBadgeColorClasses(option?.color, value);
|
|
544
640
|
|
|
@@ -557,7 +653,8 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
557
653
|
*/
|
|
558
654
|
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
559
655
|
if (!value) return <EmptyValue />;
|
|
560
|
-
|
|
656
|
+
|
|
657
|
+
const label = useFieldLabel();
|
|
561
658
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
562
659
|
const [copied, setCopied] = React.useState(false);
|
|
563
660
|
|
|
@@ -588,7 +685,7 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
588
685
|
type="button"
|
|
589
686
|
className="opacity-0 group-hover/email:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
|
590
687
|
onClick={handleCopy}
|
|
591
|
-
aria-label=
|
|
688
|
+
aria-label={label('detail.copyEmail', 'Copy email')}
|
|
592
689
|
>
|
|
593
690
|
{copied ? (
|
|
594
691
|
<Check className="h-3 w-3 text-green-600" />
|
|
@@ -630,7 +727,8 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
|
|
|
630
727
|
*/
|
|
631
728
|
export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
632
729
|
if (!value) return <EmptyValue />;
|
|
633
|
-
|
|
730
|
+
|
|
731
|
+
const label = useFieldLabel();
|
|
634
732
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
635
733
|
const [copied, setCopied] = React.useState(false);
|
|
636
734
|
|
|
@@ -657,7 +755,7 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
657
755
|
type="button"
|
|
658
756
|
className="opacity-0 group-hover/phone:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
|
659
757
|
onClick={handleCopy}
|
|
660
|
-
aria-label=
|
|
758
|
+
aria-label={label('detail.copyPhone', 'Copy phone number')}
|
|
661
759
|
>
|
|
662
760
|
{copied ? (
|
|
663
761
|
<Check className="h-3 w-3 text-green-600" />
|
|
@@ -868,6 +966,51 @@ export function registerFieldRenderer(type: string, renderer: React.FC<CellRende
|
|
|
868
966
|
fieldRegistry.set(type, renderer);
|
|
869
967
|
}
|
|
870
968
|
|
|
969
|
+
/**
|
|
970
|
+
* Format hints (e.g. `{ type: 'text', format: 'phone' }`) that should
|
|
971
|
+
* map to a richer cell renderer than the bare type would imply. The
|
|
972
|
+
* canonical ObjectStack pattern uses `Field.text({ format: 'phone' })`
|
|
973
|
+
* for plain text columns that should still render with a tel: link,
|
|
974
|
+
* `Field.text({ format: 'url' })` for clickable links, etc.
|
|
975
|
+
*
|
|
976
|
+
* Only applies when the field's base type is a generic text type;
|
|
977
|
+
* explicit types like 'phone'/'email'/'currency' already win without
|
|
978
|
+
* any format hint.
|
|
979
|
+
*/
|
|
980
|
+
const FORMAT_TO_RENDERER: Record<string, string> = {
|
|
981
|
+
phone: 'phone',
|
|
982
|
+
tel: 'phone',
|
|
983
|
+
telephone: 'phone',
|
|
984
|
+
email: 'email',
|
|
985
|
+
url: 'url',
|
|
986
|
+
uri: 'url',
|
|
987
|
+
link: 'url',
|
|
988
|
+
currency: 'currency',
|
|
989
|
+
money: 'currency',
|
|
990
|
+
percent: 'percent',
|
|
991
|
+
percentage: 'percent',
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
const TEXTUAL_BASE_TYPES = new Set(['text', 'textarea', 'string', 'longtext', '']);
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Resolve the canonical cell-renderer key for a field. Accepts either a
|
|
998
|
+
* raw type string (back-compat) or a field-metadata object so that
|
|
999
|
+
* format hints (`format: 'phone'` etc.) can promote a plain `text`
|
|
1000
|
+
* field to its richer renderer counterpart.
|
|
1001
|
+
*/
|
|
1002
|
+
export function resolveCellRendererType(fieldOrType: string | { type?: string; format?: string } | null | undefined): string {
|
|
1003
|
+
if (!fieldOrType) return 'text';
|
|
1004
|
+
if (typeof fieldOrType === 'string') return fieldOrType;
|
|
1005
|
+
const baseType = (fieldOrType.type || '').toLowerCase();
|
|
1006
|
+
const formatRaw = fieldOrType.format;
|
|
1007
|
+
const format = typeof formatRaw === 'string' ? formatRaw.toLowerCase() : '';
|
|
1008
|
+
if (format && FORMAT_TO_RENDERER[format] && TEXTUAL_BASE_TYPES.has(baseType)) {
|
|
1009
|
+
return FORMAT_TO_RENDERER[format];
|
|
1010
|
+
}
|
|
1011
|
+
return fieldOrType.type || 'text';
|
|
1012
|
+
}
|
|
1013
|
+
|
|
871
1014
|
/**
|
|
872
1015
|
* Get the appropriate cell renderer for a field type
|
|
873
1016
|
*/
|
|
@@ -3,29 +3,47 @@ import { Input, EmptyValue } from '@object-ui/components';
|
|
|
3
3
|
import { FieldWidgetProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Format currency value for display
|
|
6
|
+
* Format currency value for display. When `currency` is undefined the value
|
|
7
|
+
* is rendered as a plain number with thousands separators (no symbol),
|
|
8
|
+
* because silently assuming USD is misleading for non-USD businesses.
|
|
7
9
|
*/
|
|
8
|
-
function
|
|
10
|
+
function formatAmount(value: number, currency: string | undefined, precision: number): string {
|
|
11
|
+
if (currency) {
|
|
12
|
+
try {
|
|
13
|
+
return new Intl.NumberFormat('en-US', {
|
|
14
|
+
style: 'currency',
|
|
15
|
+
currency,
|
|
16
|
+
minimumFractionDigits: precision,
|
|
17
|
+
maximumFractionDigits: precision,
|
|
18
|
+
}).format(value);
|
|
19
|
+
} catch {
|
|
20
|
+
return `${currency} ${value.toFixed(precision)}`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
9
23
|
try {
|
|
10
24
|
return new Intl.NumberFormat('en-US', {
|
|
11
|
-
|
|
12
|
-
|
|
25
|
+
minimumFractionDigits: precision,
|
|
26
|
+
maximumFractionDigits: precision,
|
|
13
27
|
}).format(value);
|
|
14
28
|
} catch {
|
|
15
|
-
return
|
|
29
|
+
return value.toFixed(precision);
|
|
16
30
|
}
|
|
17
31
|
}
|
|
18
32
|
|
|
33
|
+
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
34
|
+
USD: '$',
|
|
35
|
+
};
|
|
36
|
+
|
|
19
37
|
export function CurrencyField({ value, onChange, field, readonly, errorMessage, className, ...props }: FieldWidgetProps<number>) {
|
|
20
38
|
const currencyField = (field || (props as any).schema) as any;
|
|
21
|
-
const currency = currencyField?.currency
|
|
39
|
+
const currency: string | undefined = currencyField?.currency;
|
|
22
40
|
const precision = currencyField?.precision ?? 2;
|
|
23
41
|
|
|
24
42
|
if (readonly) {
|
|
25
43
|
if (value == null) return <EmptyValue />;
|
|
26
44
|
return (
|
|
27
45
|
<span className="text-sm font-medium tabular-nums">
|
|
28
|
-
{
|
|
46
|
+
{formatAmount(Number(value), currency, precision)}
|
|
29
47
|
</span>
|
|
30
48
|
);
|
|
31
49
|
}
|
|
@@ -38,11 +56,15 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
|
|
|
38
56
|
}
|
|
39
57
|
};
|
|
40
58
|
|
|
59
|
+
const symbol = currency ? (currency === 'USD' ? '$' : currency) : '';
|
|
60
|
+
|
|
41
61
|
return (
|
|
42
62
|
<div className="relative">
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
63
|
+
{symbol && (
|
|
64
|
+
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
|
65
|
+
{symbol}
|
|
66
|
+
</span>
|
|
67
|
+
)}
|
|
46
68
|
<Input
|
|
47
69
|
{...props}
|
|
48
70
|
type="number"
|
|
@@ -54,7 +76,7 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
|
|
|
54
76
|
onBlur={handleBlur}
|
|
55
77
|
placeholder={currencyField?.placeholder || '0.00'}
|
|
56
78
|
disabled={readonly || props.disabled}
|
|
57
|
-
className={
|
|
79
|
+
className={`${symbol ? 'pl-8' : ''} ${className || ''}`}
|
|
58
80
|
step={Math.pow(10, -precision).toFixed(precision)}
|
|
59
81
|
aria-invalid={!!errorMessage}
|
|
60
82
|
/>
|