@object-ui/fields 4.0.7 → 4.0.9
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 +23 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +960 -658
- package/dist/packages/fields/src/index.d.ts +61 -5
- package/dist/packages/fields/src/widgets/RecordPickerDialog.d.ts +9 -1
- package/package.json +9 -9
- package/src/index.tsx +444 -76
- package/src/widgets/CurrencyField.tsx +33 -11
- package/src/widgets/LookupField.tsx +132 -9
- package/src/widgets/RecordPickerDialog.tsx +37 -1
package/src/index.tsx
CHANGED
|
@@ -9,8 +9,122 @@
|
|
|
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, Checkbox, EmptyValue } from '@object-ui/components';
|
|
12
|
+
import { Badge, Avatar, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
|
|
13
13
|
import { Check, X, Copy, Phone as PhoneIcon } from 'lucide-react';
|
|
14
|
+
import { useObjectTranslation } from '@object-ui/react';
|
|
15
|
+
import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react';
|
|
16
|
+
|
|
17
|
+
// Module-level cache so multiple renderers fetching the same lookup ID
|
|
18
|
+
// only trigger one network call. Keyed by `${objectName}:${id}`.
|
|
19
|
+
type LookupCacheEntry =
|
|
20
|
+
| { state: 'pending'; promise: Promise<void> }
|
|
21
|
+
| { state: 'ok'; name: string | undefined }
|
|
22
|
+
| { state: 'err' };
|
|
23
|
+
const lookupNameCache: Map<string, LookupCacheEntry> = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pick the most reasonable display name from an arbitrary record object.
|
|
27
|
+
* Tries common name-like keys in priority order, then falls back to undefined.
|
|
28
|
+
*/
|
|
29
|
+
function pickRecordDisplayName(record: Record<string, unknown> | null | undefined): string | undefined {
|
|
30
|
+
if (!record || typeof record !== 'object') return undefined;
|
|
31
|
+
const candidates = ['name', 'full_name', 'display_name', 'label', 'title', 'subject', 'username', 'email'];
|
|
32
|
+
for (const k of candidates) {
|
|
33
|
+
const v = record[k];
|
|
34
|
+
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
35
|
+
if (typeof v === 'number') return String(v);
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Heuristic: detect strings that look like opaque foreign-key IDs (e.g. nanoid
|
|
42
|
+
* or BSON ObjectId). Used so we don't display random gibberish to users when
|
|
43
|
+
* a lookup wasn't expanded.
|
|
44
|
+
*/
|
|
45
|
+
export function isLikelyOpaqueId(v: unknown): boolean {
|
|
46
|
+
if (typeof v !== 'string') return false;
|
|
47
|
+
// 12-32 chars, only [A-Za-z0-9_-], no whitespace.
|
|
48
|
+
if (!/^[A-Za-z0-9_-]{12,32}$/.test(v)) return false;
|
|
49
|
+
// Must have BOTH upper- and lower-case letters (real words rarely do at this length).
|
|
50
|
+
// Also accept tokens that contain `_` or `-` separators alongside any case mix.
|
|
51
|
+
const hasUpper = /[A-Z]/.test(v);
|
|
52
|
+
const hasLower = /[a-z]/.test(v);
|
|
53
|
+
const hasDigitOrSep = /[0-9_-]/.test(v);
|
|
54
|
+
return (hasUpper && hasLower) || (hasUpper && hasDigitOrSep) || (hasLower && hasDigitOrSep);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fetch-on-demand resolver for foreign-key IDs that weren't expanded by the
|
|
59
|
+
* server. Reads `dataSource` from SchemaRendererContext; safely no-ops if
|
|
60
|
+
* the context isn't installed. Returns the resolved display name or
|
|
61
|
+
* `undefined` while pending or unresolvable.
|
|
62
|
+
*/
|
|
63
|
+
function useLookupName(referenceTo: string | undefined, value: unknown): string | undefined {
|
|
64
|
+
const ctx = React.useContext(_SchemaRendererContext);
|
|
65
|
+
const dataSource = ctx?.dataSource;
|
|
66
|
+
const [, force] = React.useState(0);
|
|
67
|
+
|
|
68
|
+
const isResolvable =
|
|
69
|
+
!!referenceTo &&
|
|
70
|
+
!!dataSource &&
|
|
71
|
+
typeof dataSource.find === 'function' &&
|
|
72
|
+
(typeof value === 'string' || typeof value === 'number') &&
|
|
73
|
+
value !== '';
|
|
74
|
+
const cacheKey = isResolvable ? `${referenceTo}:${String(value)}` : '';
|
|
75
|
+
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
if (!isResolvable) return;
|
|
78
|
+
const existing = lookupNameCache.get(cacheKey);
|
|
79
|
+
if (existing && existing.state !== 'pending' && (existing as any).promise == null) return;
|
|
80
|
+
if (existing?.state === 'pending') return;
|
|
81
|
+
|
|
82
|
+
const promise: Promise<void> = (async () => {
|
|
83
|
+
try {
|
|
84
|
+
let record: Record<string, unknown> | undefined;
|
|
85
|
+
if (typeof (dataSource as any).findOne === 'function') {
|
|
86
|
+
record = await (dataSource as any).findOne(referenceTo, value);
|
|
87
|
+
} else {
|
|
88
|
+
const result = await (dataSource as any).find(referenceTo, {
|
|
89
|
+
$filter: { id: value },
|
|
90
|
+
options: { $top: 1 },
|
|
91
|
+
});
|
|
92
|
+
const records: any[] = Array.isArray(result)
|
|
93
|
+
? result
|
|
94
|
+
: (result?.value || result?.data || []);
|
|
95
|
+
record = records[0];
|
|
96
|
+
}
|
|
97
|
+
const name = pickRecordDisplayName(record);
|
|
98
|
+
lookupNameCache.set(cacheKey, { state: 'ok', name });
|
|
99
|
+
} catch {
|
|
100
|
+
lookupNameCache.set(cacheKey, { state: 'err' });
|
|
101
|
+
}
|
|
102
|
+
force((n) => n + 1);
|
|
103
|
+
})();
|
|
104
|
+
|
|
105
|
+
lookupNameCache.set(cacheKey, { state: 'pending', promise });
|
|
106
|
+
}, [cacheKey, isResolvable, referenceTo, value, dataSource]);
|
|
107
|
+
|
|
108
|
+
if (!isResolvable) return undefined;
|
|
109
|
+
const entry = lookupNameCache.get(cacheKey);
|
|
110
|
+
return entry?.state === 'ok' ? entry.name : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Safe label resolver for cell-level UI strings. Falls back to the English
|
|
115
|
+
* default when no I18nProvider is available or when the key is missing.
|
|
116
|
+
*/
|
|
117
|
+
function useFieldLabel() {
|
|
118
|
+
try {
|
|
119
|
+
const { t } = useObjectTranslation();
|
|
120
|
+
return (key: string, fallback: string) => {
|
|
121
|
+
const v = t(key);
|
|
122
|
+
return !v || v === key ? fallback : v;
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
return (_k: string, fallback: string) => fallback;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
14
128
|
|
|
15
129
|
import { TextField } from './widgets/TextField';
|
|
16
130
|
import { NumberField } from './widgets/NumberField';
|
|
@@ -127,6 +241,22 @@ export function formatCompactCurrency(value: number, currency: string = 'USD'):
|
|
|
127
241
|
}
|
|
128
242
|
}
|
|
129
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Format a plain number with thousands separators, no currency symbol.
|
|
246
|
+
* Used as a safe fallback when a currency-typed field has no `currency`
|
|
247
|
+
* configured — we'd rather render `1,234.50` than silently assume USD.
|
|
248
|
+
*/
|
|
249
|
+
export function formatNumber(value: number, decimals: number = 2): string {
|
|
250
|
+
try {
|
|
251
|
+
return new Intl.NumberFormat('en-US', {
|
|
252
|
+
minimumFractionDigits: decimals,
|
|
253
|
+
maximumFractionDigits: decimals,
|
|
254
|
+
}).format(value);
|
|
255
|
+
} catch {
|
|
256
|
+
return value.toFixed(decimals);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
130
260
|
/**
|
|
131
261
|
* Format percent value
|
|
132
262
|
* Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
|
|
@@ -153,10 +283,10 @@ export function humanizeLabel(value: string): string {
|
|
|
153
283
|
/**
|
|
154
284
|
* Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
|
|
155
285
|
*/
|
|
156
|
-
export function formatRelativeDate(value: string | Date): string {
|
|
157
|
-
if (
|
|
158
|
-
const date =
|
|
159
|
-
if (isNaN(date.getTime())) return '—';
|
|
286
|
+
export function formatRelativeDate(value: string | Date | number): string {
|
|
287
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
288
|
+
const date = value instanceof Date ? value : new Date(value as any);
|
|
289
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
160
290
|
|
|
161
291
|
const now = new Date();
|
|
162
292
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
@@ -179,10 +309,10 @@ export function formatRelativeDate(value: string | Date): string {
|
|
|
179
309
|
/**
|
|
180
310
|
* Format date value
|
|
181
311
|
*/
|
|
182
|
-
export function formatDate(value: string | Date, style?: string): string {
|
|
183
|
-
if (
|
|
184
|
-
const date =
|
|
185
|
-
if (isNaN(date.getTime())) return '—';
|
|
312
|
+
export function formatDate(value: string | Date | number, style?: string): string {
|
|
313
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
314
|
+
const date = value instanceof Date ? value : new Date(value as any);
|
|
315
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
186
316
|
|
|
187
317
|
if (style === 'short') {
|
|
188
318
|
// Compact format for mobile: "Jan 15, '24"
|
|
@@ -207,10 +337,10 @@ export function formatDate(value: string | Date, style?: string): string {
|
|
|
207
337
|
/**
|
|
208
338
|
* Format datetime value
|
|
209
339
|
*/
|
|
210
|
-
export function formatDateTime(value: string | Date): string {
|
|
211
|
-
if (
|
|
212
|
-
const date =
|
|
213
|
-
if (isNaN(date.getTime())) return '—';
|
|
340
|
+
export function formatDateTime(value: string | Date | number): string {
|
|
341
|
+
if (value === null || value === undefined || value === '') return '—';
|
|
342
|
+
const date = value instanceof Date ? value : new Date(value as any);
|
|
343
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
214
344
|
|
|
215
345
|
return date.toLocaleDateString(undefined, {
|
|
216
346
|
year: 'numeric',
|
|
@@ -255,9 +385,14 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
|
|
|
255
385
|
|
|
256
386
|
const safe = coerceToSafeValue(value);
|
|
257
387
|
const currencyField = field as any;
|
|
258
|
-
|
|
388
|
+
// Honor both `currency` (legacy/grid configs) and `defaultCurrency`
|
|
389
|
+
// (the canonical key from `@objectstack/spec` Field.currency()).
|
|
390
|
+
const currency: string | undefined = currencyField.currency || currencyField.defaultCurrency;
|
|
259
391
|
const num = Number(safe);
|
|
260
|
-
const
|
|
392
|
+
const decimals = currencyField.precision ?? currencyField.scale ?? currencyField.decimals ?? 2;
|
|
393
|
+
const formatted = !isNaN(num)
|
|
394
|
+
? (currency ? formatCurrency(num, currency) : formatNumber(num, decimals))
|
|
395
|
+
: String(safe);
|
|
261
396
|
|
|
262
397
|
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
|
|
263
398
|
}
|
|
@@ -290,7 +425,7 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
290
425
|
return (
|
|
291
426
|
<div className="flex items-center gap-2">
|
|
292
427
|
<div
|
|
293
|
-
className="h-1.5 w-16 rounded-full bg-muted overflow-hidden shrink-0"
|
|
428
|
+
className="h-1.5 w-16 rounded-full bg-muted ring-1 ring-inset ring-border/60 overflow-hidden shrink-0"
|
|
294
429
|
role="progressbar"
|
|
295
430
|
aria-valuenow={clampedBar}
|
|
296
431
|
aria-valuemin={0}
|
|
@@ -351,7 +486,7 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
351
486
|
}
|
|
352
487
|
|
|
353
488
|
return (
|
|
354
|
-
<div className="flex items-center justify-
|
|
489
|
+
<div className="flex items-center justify-start">
|
|
355
490
|
<Checkbox checked={!!value} disabled className="pointer-events-none" />
|
|
356
491
|
</div>
|
|
357
492
|
);
|
|
@@ -370,16 +505,16 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
370
505
|
// Determine if date is overdue (in the past) — but only color it red when the
|
|
371
506
|
// field is *semantically* a due/deadline. A plain "start_date" or "created_at"
|
|
372
507
|
// in the past should not render in red.
|
|
373
|
-
const date =
|
|
374
|
-
const isValidDate = date
|
|
508
|
+
const date = safe != null ? new Date(safe as string | number) : null;
|
|
509
|
+
const isValidDate = date !== null && !isNaN(date.getTime());
|
|
375
510
|
const startOfToday = new Date();
|
|
376
511
|
startOfToday.setHours(0, 0, 0, 0);
|
|
377
512
|
const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
|
|
378
513
|
const dueLike =
|
|
379
514
|
dateField?.dueLike === true ||
|
|
380
515
|
/(^|_)(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
|
|
516
|
+
const isOverdue = dueLike && isValidDate && date! < startOfToday;
|
|
517
|
+
const isoString = isValidDate ? date!.toISOString() : String(safe);
|
|
383
518
|
|
|
384
519
|
return (
|
|
385
520
|
<span
|
|
@@ -397,8 +532,8 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
397
532
|
export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
398
533
|
if (!value) return <EmptyValue />;
|
|
399
534
|
const safe = coerceToSafeValue(value);
|
|
400
|
-
const date =
|
|
401
|
-
if (
|
|
535
|
+
const date = safe != null ? new Date(safe as string | number) : null;
|
|
536
|
+
if (date === null || isNaN(date.getTime())) return <EmptyValue />;
|
|
402
537
|
|
|
403
538
|
const datePart = date.toLocaleDateString(undefined, {
|
|
404
539
|
month: 'numeric',
|
|
@@ -421,8 +556,9 @@ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactE
|
|
|
421
556
|
|
|
422
557
|
// Semantic color mapping (auto-detect from value text for priority & status fields)
|
|
423
558
|
// Keys use underscore notation; lookup normalizes spaces/hyphens to underscores automatically.
|
|
559
|
+
// Chinese keys are stored as-is and matched directly (no normalization side-effects).
|
|
424
560
|
const SEMANTIC_COLOR_MAP: Record<string, string> = {
|
|
425
|
-
// Priority values
|
|
561
|
+
// Priority values (en)
|
|
426
562
|
critical: 'red',
|
|
427
563
|
urgent: 'red',
|
|
428
564
|
high: 'orange',
|
|
@@ -430,7 +566,7 @@ const SEMANTIC_COLOR_MAP: Record<string, string> = {
|
|
|
430
566
|
normal: 'blue',
|
|
431
567
|
low: 'gray',
|
|
432
568
|
none: 'gray',
|
|
433
|
-
// Status values
|
|
569
|
+
// Status values (en)
|
|
434
570
|
paid: 'green',
|
|
435
571
|
completed: 'green',
|
|
436
572
|
done: 'green',
|
|
@@ -455,6 +591,52 @@ const SEMANTIC_COLOR_MAP: Record<string, string> = {
|
|
|
455
591
|
overdue: 'red',
|
|
456
592
|
delivered: 'purple',
|
|
457
593
|
archived: 'indigo',
|
|
594
|
+
// CRM lifecycle values (en)
|
|
595
|
+
contacted: 'blue',
|
|
596
|
+
qualified: 'purple',
|
|
597
|
+
converted: 'green',
|
|
598
|
+
won: 'green',
|
|
599
|
+
lost: 'red',
|
|
600
|
+
// Priority values (zh)
|
|
601
|
+
紧急: 'red',
|
|
602
|
+
严重: 'red',
|
|
603
|
+
高: 'orange',
|
|
604
|
+
中: 'yellow',
|
|
605
|
+
普通: 'blue',
|
|
606
|
+
低: 'gray',
|
|
607
|
+
无: 'gray',
|
|
608
|
+
// Status values (zh)
|
|
609
|
+
新建: 'gray',
|
|
610
|
+
草稿: 'gray',
|
|
611
|
+
待处理: 'yellow',
|
|
612
|
+
待审核: 'yellow',
|
|
613
|
+
待联系: 'yellow',
|
|
614
|
+
挂起: 'yellow',
|
|
615
|
+
进行中: 'blue',
|
|
616
|
+
处理中: 'blue',
|
|
617
|
+
已联系: 'blue',
|
|
618
|
+
跟进中: 'blue',
|
|
619
|
+
已发货: 'blue',
|
|
620
|
+
打开: 'blue',
|
|
621
|
+
已确认: 'green',
|
|
622
|
+
已审核: 'green',
|
|
623
|
+
已通过: 'green',
|
|
624
|
+
已完成: 'green',
|
|
625
|
+
已支付: 'green',
|
|
626
|
+
已签收: 'green',
|
|
627
|
+
已转化: 'green',
|
|
628
|
+
成单: 'green',
|
|
629
|
+
赢得: 'green',
|
|
630
|
+
已签约: 'green',
|
|
631
|
+
已交付: 'purple',
|
|
632
|
+
已归档: 'indigo',
|
|
633
|
+
已关闭: 'gray',
|
|
634
|
+
已取消: 'red',
|
|
635
|
+
已拒绝: 'red',
|
|
636
|
+
失败: 'red',
|
|
637
|
+
逾期: 'red',
|
|
638
|
+
流失: 'red',
|
|
639
|
+
丢失: 'red',
|
|
458
640
|
};
|
|
459
641
|
|
|
460
642
|
// Color to Tailwind class mapping for custom Badge styling
|
|
@@ -475,6 +657,22 @@ const BADGE_COLOR_MAP: Record<string, string> = {
|
|
|
475
657
|
pink: 'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-950/40 dark:text-pink-300 dark:border-pink-900/60',
|
|
476
658
|
};
|
|
477
659
|
|
|
660
|
+
// Solid color → Tailwind background class for the small dot used by the
|
|
661
|
+
// `appearance: 'dot'` rendering of select/status fields. Uses the -500 shade
|
|
662
|
+
// for both light and dark modes so the dot remains a clear visual anchor
|
|
663
|
+
// without becoming a heavy color block.
|
|
664
|
+
const DOT_COLOR_MAP: Record<string, string> = {
|
|
665
|
+
gray: 'bg-gray-400 dark:bg-gray-500',
|
|
666
|
+
red: 'bg-red-500',
|
|
667
|
+
orange: 'bg-orange-500',
|
|
668
|
+
yellow: 'bg-yellow-500',
|
|
669
|
+
green: 'bg-green-500',
|
|
670
|
+
blue: 'bg-blue-500',
|
|
671
|
+
indigo: 'bg-indigo-500',
|
|
672
|
+
purple: 'bg-purple-500',
|
|
673
|
+
pink: 'bg-pink-500',
|
|
674
|
+
};
|
|
675
|
+
|
|
478
676
|
// Color palette used by the deterministic fallback when no schema/semantic
|
|
479
677
|
// color matches. Excludes 'gray' to ensure visual contrast between values.
|
|
480
678
|
const BADGE_FALLBACK_PALETTE: readonly string[] = [
|
|
@@ -494,7 +692,7 @@ function hashToColor(value: string): string {
|
|
|
494
692
|
return BADGE_FALLBACK_PALETTE[idx];
|
|
495
693
|
}
|
|
496
694
|
|
|
497
|
-
function getBadgeColorClasses(color?: string, val?: unknown): string {
|
|
695
|
+
export function getBadgeColorClasses(color?: string, val?: unknown): string {
|
|
498
696
|
if (color && BADGE_COLOR_MAP[color]) return BADGE_COLOR_MAP[color];
|
|
499
697
|
if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
|
|
500
698
|
const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
|
|
@@ -506,50 +704,121 @@ function getBadgeColorClasses(color?: string, val?: unknown): string {
|
|
|
506
704
|
}
|
|
507
705
|
|
|
508
706
|
/**
|
|
509
|
-
*
|
|
707
|
+
* Resolve a semantic color name (e.g. "red", "green") for a value, suitable
|
|
708
|
+
* for callers that need a raw color token rather than CSS classes (for
|
|
709
|
+
* example, the Gantt renderer paints bars via inline styles).
|
|
710
|
+
*
|
|
711
|
+
* Resolution order: explicit option color → semantic value mapping →
|
|
712
|
+
* deterministic hash fallback. Returns `undefined` only when no value is
|
|
713
|
+
* supplied so the caller can fall back to its own default.
|
|
714
|
+
*/
|
|
715
|
+
export function getSemanticColorName(color?: string, val?: unknown): string | undefined {
|
|
716
|
+
if (color && BADGE_COLOR_MAP[color]) return color;
|
|
717
|
+
if (val == null || val === '') return undefined;
|
|
718
|
+
const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
|
|
719
|
+
const semantic = SEMANTIC_COLOR_MAP[key];
|
|
720
|
+
if (semantic) return semantic;
|
|
721
|
+
return hashToColor(key);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Resolved hex values for the -500 shade of each palette color. Mirrors
|
|
725
|
+
// `DOT_COLOR_MAP` and is consumed by callers that paint via inline styles
|
|
726
|
+
// (e.g. Gantt task bars, where Tailwind classes can't be applied to dynamic
|
|
727
|
+
// `style={}` values).
|
|
728
|
+
const COLOR_NAME_HEX: Record<string, string> = {
|
|
729
|
+
gray: '#6b7280',
|
|
730
|
+
red: '#ef4444',
|
|
731
|
+
orange: '#f97316',
|
|
732
|
+
yellow: '#eab308',
|
|
733
|
+
green: '#22c55e',
|
|
734
|
+
blue: '#3b82f6',
|
|
735
|
+
indigo: '#6366f1',
|
|
736
|
+
purple: '#a855f7',
|
|
737
|
+
pink: '#ec4899',
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Map a semantic color name to its Tailwind -500 hex value. Used by
|
|
742
|
+
* inline-style consumers (Gantt bars). Falls back to the supplied default
|
|
743
|
+
* (or the platform default blue) when the name is unrecognized.
|
|
744
|
+
*/
|
|
745
|
+
export function getSemanticHex(name?: string, fallback: string = '#3b82f6'): string {
|
|
746
|
+
if (!name) return fallback;
|
|
747
|
+
return COLOR_NAME_HEX[name] ?? fallback;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Select field cell renderer.
|
|
752
|
+
*
|
|
753
|
+
* Two visual styles, controlled by `field.appearance` (renderer-level option,
|
|
754
|
+
* not part of the `@objectstack/spec` field schema):
|
|
755
|
+
* - `'badge'` (default for spec compatibility): soft-pill colored badge.
|
|
756
|
+
* - `'dot'`: a small colored dot followed by the option label. Used by
|
|
757
|
+
* dense list/grid contexts to keep the table visually quiet — repeated
|
|
758
|
+
* filled badges across many rows create heavy visual noise.
|
|
759
|
+
*
|
|
760
|
+
* Metadata always wins: callers can pass `appearance: 'badge'` on the field
|
|
761
|
+
* descriptor to force the legacy badge in any context.
|
|
510
762
|
*/
|
|
511
763
|
export function SelectCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
512
764
|
const selectField = field as any;
|
|
513
765
|
const options: SelectOptionMetadata[] = selectField.options || [];
|
|
514
|
-
|
|
766
|
+
const appearance: 'badge' | 'dot' = selectField.appearance === 'dot' ? 'dot' : 'badge';
|
|
767
|
+
|
|
515
768
|
if (value == null || value === '') return <EmptyValue />;
|
|
516
|
-
|
|
769
|
+
|
|
770
|
+
// Match a stored value to a configured option, falling back to a
|
|
771
|
+
// case-insensitive comparison so seed data with mixed case
|
|
772
|
+
// (e.g. "Referral" stored, "referral" defined) still resolves to the
|
|
773
|
+
// localized option label.
|
|
774
|
+
const findOption = (val: any): SelectOptionMetadata | undefined => {
|
|
775
|
+
const exact = options.find(opt => opt.value === val);
|
|
776
|
+
if (exact) return exact;
|
|
777
|
+
const norm = String(val).toLowerCase();
|
|
778
|
+
return options.find(opt => String(opt.value).toLowerCase() === norm);
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
const renderOne = (val: any, key?: number): React.ReactElement => {
|
|
782
|
+
const option = findOption(val);
|
|
783
|
+
const label = option?.label || humanizeLabel(String(val));
|
|
784
|
+
|
|
785
|
+
if (appearance === 'dot') {
|
|
786
|
+
// Resolve a real CSS color for the dot. Prefer explicit option color,
|
|
787
|
+
// then semantic mapping for the value, then deterministic palette.
|
|
788
|
+
const colorName = option?.color
|
|
789
|
+
|| SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
|
|
790
|
+
|| hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
|
|
791
|
+
const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;
|
|
792
|
+
return (
|
|
793
|
+
<span key={key} className="inline-flex items-center gap-1.5 text-sm">
|
|
794
|
+
<span className={cn('h-1.5 w-1.5 rounded-full shrink-0', dotClass)} aria-hidden="true" />
|
|
795
|
+
<span className="truncate">{label}</span>
|
|
796
|
+
</span>
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
const colorClasses = getBadgeColorClasses(option?.color, val);
|
|
801
|
+
return (
|
|
802
|
+
<Badge
|
|
803
|
+
key={key}
|
|
804
|
+
variant="outline"
|
|
805
|
+
className={colorClasses}
|
|
806
|
+
>
|
|
807
|
+
{label}
|
|
808
|
+
</Badge>
|
|
809
|
+
);
|
|
810
|
+
};
|
|
811
|
+
|
|
517
812
|
// Handle multiple values
|
|
518
813
|
if (Array.isArray(value)) {
|
|
519
814
|
return (
|
|
520
|
-
<div className=
|
|
521
|
-
{value.map((val, idx) =>
|
|
522
|
-
const option = options.find(opt => opt.value === val);
|
|
523
|
-
const label = option?.label || humanizeLabel(String(val));
|
|
524
|
-
const colorClasses = getBadgeColorClasses(option?.color, val);
|
|
525
|
-
|
|
526
|
-
return (
|
|
527
|
-
<Badge
|
|
528
|
-
key={idx}
|
|
529
|
-
variant="outline"
|
|
530
|
-
className={colorClasses}
|
|
531
|
-
>
|
|
532
|
-
{label}
|
|
533
|
-
</Badge>
|
|
534
|
-
);
|
|
535
|
-
})}
|
|
815
|
+
<div className={cn('flex flex-wrap', appearance === 'dot' ? 'gap-x-3 gap-y-1' : 'gap-1')}>
|
|
816
|
+
{value.map((val, idx) => renderOne(val, idx))}
|
|
536
817
|
</div>
|
|
537
818
|
);
|
|
538
819
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const option = options.find(opt => opt.value === value);
|
|
542
|
-
const label = option?.label || humanizeLabel(String(value));
|
|
543
|
-
const colorClasses = getBadgeColorClasses(option?.color, value);
|
|
544
|
-
|
|
545
|
-
return (
|
|
546
|
-
<Badge
|
|
547
|
-
variant="outline"
|
|
548
|
-
className={colorClasses}
|
|
549
|
-
>
|
|
550
|
-
{label}
|
|
551
|
-
</Badge>
|
|
552
|
-
);
|
|
820
|
+
|
|
821
|
+
return renderOne(value);
|
|
553
822
|
}
|
|
554
823
|
|
|
555
824
|
/**
|
|
@@ -557,7 +826,8 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
557
826
|
*/
|
|
558
827
|
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
559
828
|
if (!value) return <EmptyValue />;
|
|
560
|
-
|
|
829
|
+
|
|
830
|
+
const label = useFieldLabel();
|
|
561
831
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
562
832
|
const [copied, setCopied] = React.useState(false);
|
|
563
833
|
|
|
@@ -588,7 +858,7 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
588
858
|
type="button"
|
|
589
859
|
className="opacity-0 group-hover/email:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
|
590
860
|
onClick={handleCopy}
|
|
591
|
-
aria-label=
|
|
861
|
+
aria-label={label('detail.copyEmail', 'Copy email')}
|
|
592
862
|
>
|
|
593
863
|
{copied ? (
|
|
594
864
|
<Check className="h-3 w-3 text-green-600" />
|
|
@@ -630,7 +900,8 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
|
|
|
630
900
|
*/
|
|
631
901
|
export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
632
902
|
if (!value) return <EmptyValue />;
|
|
633
|
-
|
|
903
|
+
|
|
904
|
+
const label = useFieldLabel();
|
|
634
905
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
635
906
|
const [copied, setCopied] = React.useState(false);
|
|
636
907
|
|
|
@@ -657,7 +928,7 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
657
928
|
type="button"
|
|
658
929
|
className="opacity-0 group-hover/phone:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
|
659
930
|
onClick={handleCopy}
|
|
660
|
-
aria-label=
|
|
931
|
+
aria-label={label('detail.copyPhone', 'Copy phone number')}
|
|
661
932
|
>
|
|
662
933
|
{copied ? (
|
|
663
934
|
<Check className="h-3 w-3 text-green-600" />
|
|
@@ -727,21 +998,62 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
727
998
|
}
|
|
728
999
|
|
|
729
1000
|
/**
|
|
730
|
-
* Lookup/Master-Detail field cell renderer
|
|
1001
|
+
* Lookup/Master-Detail field cell renderer.
|
|
1002
|
+
*
|
|
1003
|
+
* Display order:
|
|
1004
|
+
* 1. Embedded record object (`{ id, name, ... }` from `$expand`) → use its name
|
|
1005
|
+
* 2. Static `field.options[]` (e.g. when the lookup is a closed enum) → look up label
|
|
1006
|
+
* 3. Fetch-on-demand: when the value is a primitive ID and `field.reference_to`
|
|
1007
|
+
* is known, resolve via dataSource and show the related record's display name.
|
|
1008
|
+
* Falls back to a muted placeholder while pending and on failure.
|
|
731
1009
|
*/
|
|
732
1010
|
export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
1011
|
+
const referenceTo = (field as { reference_to?: string }).reference_to;
|
|
1012
|
+
|
|
1013
|
+
// Pick the FIRST primitive id we see (for arrays, only the first one is auto-resolved
|
|
1014
|
+
// to keep the cell cheap; multi-value lookups should generally be expanded server-side).
|
|
1015
|
+
const primaryPrimitiveId = (() => {
|
|
1016
|
+
if (Array.isArray(value)) {
|
|
1017
|
+
const firstPrimitive = value.find(
|
|
1018
|
+
(v) => v != null && (typeof v === 'string' || typeof v === 'number') && v !== '',
|
|
1019
|
+
);
|
|
1020
|
+
return firstPrimitive;
|
|
1021
|
+
}
|
|
1022
|
+
if (
|
|
1023
|
+
value != null &&
|
|
1024
|
+
value !== '' &&
|
|
1025
|
+
(typeof value === 'string' || typeof value === 'number') &&
|
|
1026
|
+
typeof value !== 'object'
|
|
1027
|
+
) {
|
|
1028
|
+
return value;
|
|
1029
|
+
}
|
|
1030
|
+
return undefined;
|
|
1031
|
+
})();
|
|
1032
|
+
|
|
1033
|
+
// Always call the hook (rules of hooks). It safely no-ops when inputs are missing.
|
|
1034
|
+
const resolvedName = useLookupName(referenceTo, primaryPrimitiveId);
|
|
1035
|
+
|
|
733
1036
|
if (value == null || value === '') return <EmptyValue />;
|
|
734
1037
|
|
|
735
1038
|
const options: Array<{ value: unknown; label: string }> =
|
|
736
1039
|
(field as { options?: Array<{ value: unknown; label: string }> }).options || [];
|
|
737
1040
|
|
|
738
|
-
// Resolve a primitive ID to a label
|
|
739
|
-
|
|
1041
|
+
// Resolve a primitive ID to a label. Order:
|
|
1042
|
+
// options → server-resolved name (via useLookupName) → muted placeholder for opaque IDs → raw value
|
|
1043
|
+
const resolveLabel = (val: unknown): { text: string; muted: boolean } => {
|
|
740
1044
|
if (options.length > 0) {
|
|
741
1045
|
const found = options.find((opt) => String(opt.value) === String(val));
|
|
742
|
-
if (found) return found.label;
|
|
1046
|
+
if (found) return { text: found.label, muted: false };
|
|
1047
|
+
}
|
|
1048
|
+
if (val === primaryPrimitiveId && resolvedName) {
|
|
1049
|
+
return { text: resolvedName, muted: false };
|
|
743
1050
|
}
|
|
744
|
-
|
|
1051
|
+
if (isLikelyOpaqueId(val)) {
|
|
1052
|
+
// Don't dump a random-looking ID at the user. Show a soft placeholder
|
|
1053
|
+
// that conveys "this is a reference, name unavailable".
|
|
1054
|
+
return { text: '—', muted: true };
|
|
1055
|
+
}
|
|
1056
|
+
return { text: String(val), muted: false };
|
|
745
1057
|
};
|
|
746
1058
|
|
|
747
1059
|
if (Array.isArray(value)) {
|
|
@@ -749,15 +1061,23 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
749
1061
|
<div className="flex flex-wrap gap-1">
|
|
750
1062
|
{value.map((item, idx) => {
|
|
751
1063
|
let label: string;
|
|
1064
|
+
let muted = false;
|
|
752
1065
|
if (item != null && typeof item === 'object') {
|
|
753
|
-
label = item
|
|
1066
|
+
label = pickRecordDisplayName(item as Record<string, unknown>) || String((item as any).id || (item as any)._id || '[Object]');
|
|
754
1067
|
} else {
|
|
755
|
-
|
|
1068
|
+
const r = resolveLabel(item);
|
|
1069
|
+
label = r.text;
|
|
1070
|
+
muted = r.muted;
|
|
756
1071
|
}
|
|
757
1072
|
return (
|
|
758
1073
|
<span
|
|
759
1074
|
key={idx}
|
|
760
|
-
className=
|
|
1075
|
+
className={cn(
|
|
1076
|
+
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
|
1077
|
+
muted
|
|
1078
|
+
? 'bg-muted/40 text-muted-foreground'
|
|
1079
|
+
: 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
|
|
1080
|
+
)}
|
|
761
1081
|
>
|
|
762
1082
|
{label}
|
|
763
1083
|
</span>
|
|
@@ -766,14 +1086,17 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
766
1086
|
</div>
|
|
767
1087
|
);
|
|
768
1088
|
}
|
|
769
|
-
|
|
1089
|
+
|
|
770
1090
|
if (typeof value === 'object' && value !== null) {
|
|
771
|
-
const label =
|
|
1091
|
+
const label =
|
|
1092
|
+
pickRecordDisplayName(value as Record<string, unknown>) ||
|
|
1093
|
+
String((value as any).id || (value as any)._id || '[Object]');
|
|
772
1094
|
return <span className="truncate">{label}</span>;
|
|
773
1095
|
}
|
|
774
|
-
|
|
775
|
-
// Primitive value (e.g. raw ID): try
|
|
776
|
-
|
|
1096
|
+
|
|
1097
|
+
// Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw
|
|
1098
|
+
const { text, muted } = resolveLabel(value);
|
|
1099
|
+
return <span className={cn('truncate', muted && 'text-muted-foreground')}>{text}</span>;
|
|
777
1100
|
}
|
|
778
1101
|
|
|
779
1102
|
/**
|
|
@@ -868,6 +1191,51 @@ export function registerFieldRenderer(type: string, renderer: React.FC<CellRende
|
|
|
868
1191
|
fieldRegistry.set(type, renderer);
|
|
869
1192
|
}
|
|
870
1193
|
|
|
1194
|
+
/**
|
|
1195
|
+
* Format hints (e.g. `{ type: 'text', format: 'phone' }`) that should
|
|
1196
|
+
* map to a richer cell renderer than the bare type would imply. The
|
|
1197
|
+
* canonical ObjectStack pattern uses `Field.text({ format: 'phone' })`
|
|
1198
|
+
* for plain text columns that should still render with a tel: link,
|
|
1199
|
+
* `Field.text({ format: 'url' })` for clickable links, etc.
|
|
1200
|
+
*
|
|
1201
|
+
* Only applies when the field's base type is a generic text type;
|
|
1202
|
+
* explicit types like 'phone'/'email'/'currency' already win without
|
|
1203
|
+
* any format hint.
|
|
1204
|
+
*/
|
|
1205
|
+
const FORMAT_TO_RENDERER: Record<string, string> = {
|
|
1206
|
+
phone: 'phone',
|
|
1207
|
+
tel: 'phone',
|
|
1208
|
+
telephone: 'phone',
|
|
1209
|
+
email: 'email',
|
|
1210
|
+
url: 'url',
|
|
1211
|
+
uri: 'url',
|
|
1212
|
+
link: 'url',
|
|
1213
|
+
currency: 'currency',
|
|
1214
|
+
money: 'currency',
|
|
1215
|
+
percent: 'percent',
|
|
1216
|
+
percentage: 'percent',
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
const TEXTUAL_BASE_TYPES = new Set(['text', 'textarea', 'string', 'longtext', '']);
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Resolve the canonical cell-renderer key for a field. Accepts either a
|
|
1223
|
+
* raw type string (back-compat) or a field-metadata object so that
|
|
1224
|
+
* format hints (`format: 'phone'` etc.) can promote a plain `text`
|
|
1225
|
+
* field to its richer renderer counterpart.
|
|
1226
|
+
*/
|
|
1227
|
+
export function resolveCellRendererType(fieldOrType: string | { type?: string; format?: string } | null | undefined): string {
|
|
1228
|
+
if (!fieldOrType) return 'text';
|
|
1229
|
+
if (typeof fieldOrType === 'string') return fieldOrType;
|
|
1230
|
+
const baseType = (fieldOrType.type || '').toLowerCase();
|
|
1231
|
+
const formatRaw = fieldOrType.format;
|
|
1232
|
+
const format = typeof formatRaw === 'string' ? formatRaw.toLowerCase() : '';
|
|
1233
|
+
if (format && FORMAT_TO_RENDERER[format] && TEXTUAL_BASE_TYPES.has(baseType)) {
|
|
1234
|
+
return FORMAT_TO_RENDERER[format];
|
|
1235
|
+
}
|
|
1236
|
+
return fieldOrType.type || 'text';
|
|
1237
|
+
}
|
|
1238
|
+
|
|
871
1239
|
/**
|
|
872
1240
|
* Get the appropriate cell renderer for a field type
|
|
873
1241
|
*/
|