@object-ui/fields 14.0.0 → 15.0.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/CHANGELOG.md +173 -0
- package/README.md +11 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +20 -8
- package/dist/index.js +1222 -1033
- package/dist/widgets/FileField.d.ts +22 -0
- package/dist/widgets/GridField.d.ts +6 -2
- package/dist/widgets/SelectField.d.ts +1 -1
- package/package.json +7 -7
- package/src/__tests__/DateCellRenderer.test.tsx +27 -4
- package/src/__tests__/lookupCellDisplayField.test.tsx +107 -0
- package/src/__tests__/lookupCellDisplayName.test.tsx +119 -0
- package/src/index.tsx +219 -39
- package/src/standard-widgets.test.tsx +35 -0
- package/src/widgets/CurrencyField.tsx +5 -0
- package/src/widgets/FileField.tsx +170 -28
- package/src/widgets/GridField.test.tsx +84 -1
- package/src/widgets/GridField.tsx +32 -2
- package/src/widgets/LookupField.tsx +12 -2
- package/src/widgets/NumberField.tsx +14 -2
- package/src/widgets/PeoplePicker.test.tsx +6 -0
- package/src/widgets/PeoplePicker.tsx +11 -2
- package/src/widgets/SelectField.cascade.test.tsx +3 -3
- package/src/widgets/SelectField.tsx +1 -1
package/src/index.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
|
|
11
|
-
import { ComponentRegistry, percentDisplayValue } from '@object-ui/core';
|
|
11
|
+
import { ComponentRegistry, percentDisplayValue, getRecordDisplayName } from '@object-ui/core';
|
|
12
12
|
import { useLocalization } from '@object-ui/i18n';
|
|
13
13
|
import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
|
|
14
14
|
import { Check, X, Copy, Phone as PhoneIcon, MapPin } from 'lucide-react';
|
|
@@ -78,6 +78,97 @@ export function pickRecordDisplayName(
|
|
|
78
78
|
return undefined;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// Module-level cache of referenced-object schemas (keyed by object name) so
|
|
82
|
+
// many lookup cells pointing at the same object trigger ONE metadata fetch.
|
|
83
|
+
type RefSchemaCacheEntry =
|
|
84
|
+
| { state: 'pending'; promise: Promise<void> }
|
|
85
|
+
| { state: 'ok'; schema: unknown }
|
|
86
|
+
| { state: 'err' };
|
|
87
|
+
const refObjectSchemaCache: Map<string, RefSchemaCacheEntry> = new Map();
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fetch (and cache) the referenced object's schema via
|
|
91
|
+
* `dataSource.getObjectSchema`. Resolves to `undefined` when the data source
|
|
92
|
+
* doesn't expose schema metadata or the fetch fails — callers fall back to the
|
|
93
|
+
* key heuristic in that case.
|
|
94
|
+
*/
|
|
95
|
+
async function fetchRefObjectSchema(dataSource: any, referenceTo: string): Promise<any> {
|
|
96
|
+
const settled = () => {
|
|
97
|
+
const entry = refObjectSchemaCache.get(referenceTo);
|
|
98
|
+
return entry?.state === 'ok' ? entry.schema : undefined;
|
|
99
|
+
};
|
|
100
|
+
const existing = refObjectSchemaCache.get(referenceTo);
|
|
101
|
+
if (existing?.state === 'pending') {
|
|
102
|
+
await existing.promise;
|
|
103
|
+
return settled();
|
|
104
|
+
}
|
|
105
|
+
if (existing) return settled();
|
|
106
|
+
const getSchema = dataSource?.getObjectSchema;
|
|
107
|
+
if (typeof getSchema !== 'function') return undefined;
|
|
108
|
+
const promise = Promise.resolve()
|
|
109
|
+
.then(() => getSchema.call(dataSource, referenceTo))
|
|
110
|
+
.then((schema: unknown) => { refObjectSchemaCache.set(referenceTo, { state: 'ok', schema }); })
|
|
111
|
+
.catch(() => { refObjectSchemaCache.set(referenceTo, { state: 'err' }); });
|
|
112
|
+
refObjectSchemaCache.set(referenceTo, { state: 'pending', promise });
|
|
113
|
+
await promise;
|
|
114
|
+
return settled();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* React hook wrapper over {@link fetchRefObjectSchema}: returns the referenced
|
|
119
|
+
* object's schema once loaded (re-rendering when it lands), `undefined` while
|
|
120
|
+
* pending or when schema metadata isn't available.
|
|
121
|
+
*/
|
|
122
|
+
function useRefObjectSchema(referenceTo: string | undefined): any {
|
|
123
|
+
const ctx = React.useContext(_SchemaRendererContext);
|
|
124
|
+
const dataSource = ctx?.dataSource as any;
|
|
125
|
+
const [, force] = React.useState(0);
|
|
126
|
+
const canFetch =
|
|
127
|
+
!!referenceTo && !!dataSource && typeof dataSource.getObjectSchema === 'function';
|
|
128
|
+
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
if (!canFetch) return;
|
|
131
|
+
const entry = refObjectSchemaCache.get(referenceTo!);
|
|
132
|
+
if (entry && entry.state !== 'pending') return; // settled before this render
|
|
133
|
+
let alive = true;
|
|
134
|
+
fetchRefObjectSchema(dataSource, referenceTo!).then(() => {
|
|
135
|
+
if (alive) force((n) => n + 1);
|
|
136
|
+
});
|
|
137
|
+
return () => { alive = false; };
|
|
138
|
+
}, [canFetch, referenceTo, dataSource]);
|
|
139
|
+
|
|
140
|
+
if (!canFetch) return undefined;
|
|
141
|
+
const entry = refObjectSchemaCache.get(referenceTo!);
|
|
142
|
+
return entry?.state === 'ok' ? entry.schema : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve an expanded/fetched lookup record to its display name the same way
|
|
147
|
+
* the picker does (issue #2357): explicit `displayField` first, then the
|
|
148
|
+
* unified ADR-0079 resolver against the referenced object's schema
|
|
149
|
+
* (`nameField`/`displayNameField` → `titleFormat` → type-aware derivation,
|
|
150
|
+
* which excludes autonumber). Only when no schema is available does it fall
|
|
151
|
+
* back to {@link pickRecordDisplayName}, whose `_number`/`_code` suffix scan
|
|
152
|
+
* can otherwise surface an autonumber (`0001`) over the record's real name.
|
|
153
|
+
*/
|
|
154
|
+
function resolveLookupRecordName(
|
|
155
|
+
record: Record<string, unknown> | null | undefined,
|
|
156
|
+
refSchema?: any,
|
|
157
|
+
displayField?: string,
|
|
158
|
+
): string | undefined {
|
|
159
|
+
if (!record || typeof record !== 'object') return undefined;
|
|
160
|
+
if (refSchema) {
|
|
161
|
+
const resolved = getRecordDisplayName(refSchema, record, { titleField: displayField });
|
|
162
|
+
// Stop short of the resolver's `Record #<id>` / `Untitled` floor so the
|
|
163
|
+
// cell keeps its own id-placeholder handling for nameless records.
|
|
164
|
+
const id = (record as any).id ?? (record as any)._id;
|
|
165
|
+
const isFloor =
|
|
166
|
+
resolved === 'Untitled' || (id != null && resolved === `Record #${id}`);
|
|
167
|
+
if (!isFloor && resolved) return resolved;
|
|
168
|
+
}
|
|
169
|
+
return pickRecordDisplayName(record, displayField);
|
|
170
|
+
}
|
|
171
|
+
|
|
81
172
|
/**
|
|
82
173
|
* Heuristic: detect strings that look like opaque foreign-key IDs (e.g. nanoid
|
|
83
174
|
* or BSON ObjectId). Used so we don't display random gibberish to users when
|
|
@@ -101,7 +192,11 @@ export function isLikelyOpaqueId(v: unknown): boolean {
|
|
|
101
192
|
* the context isn't installed. Returns the resolved display name or
|
|
102
193
|
* `undefined` while pending or unresolvable.
|
|
103
194
|
*/
|
|
104
|
-
function useLookupName(
|
|
195
|
+
function useLookupName(
|
|
196
|
+
referenceTo: string | undefined,
|
|
197
|
+
value: unknown,
|
|
198
|
+
displayField?: string,
|
|
199
|
+
): string | undefined {
|
|
105
200
|
const ctx = React.useContext(_SchemaRendererContext);
|
|
106
201
|
const dataSource = ctx?.dataSource;
|
|
107
202
|
const [, force] = React.useState(0);
|
|
@@ -112,7 +207,12 @@ function useLookupName(referenceTo: string | undefined, value: unknown): string
|
|
|
112
207
|
typeof dataSource.find === 'function' &&
|
|
113
208
|
(typeof value === 'string' || typeof value === 'number') &&
|
|
114
209
|
value !== '';
|
|
115
|
-
|
|
210
|
+
// The preferred display field is part of the cache identity: two columns
|
|
211
|
+
// targeting the same record with different `display_field`s must not
|
|
212
|
+
// serve each other's cached name (#2926 ⑧).
|
|
213
|
+
const cacheKey = isResolvable
|
|
214
|
+
? `${referenceTo}:${String(value)}:${displayField ?? ''}`
|
|
215
|
+
: '';
|
|
116
216
|
|
|
117
217
|
React.useEffect(() => {
|
|
118
218
|
if (!isResolvable) return;
|
|
@@ -135,7 +235,11 @@ function useLookupName(referenceTo: string | undefined, value: unknown): string
|
|
|
135
235
|
: (result?.value || result?.data || []);
|
|
136
236
|
record = records[0];
|
|
137
237
|
}
|
|
138
|
-
|
|
238
|
+
// Resolve through the referenced object's schema (nameField /
|
|
239
|
+
// titleFormat) so the chip agrees with the picker — the bare key
|
|
240
|
+
// heuristic alone can surface an autonumber over the real name.
|
|
241
|
+
const schema = await fetchRefObjectSchema(dataSource, referenceTo!);
|
|
242
|
+
const name = resolveLookupRecordName(record, schema, displayField);
|
|
139
243
|
lookupNameCache.set(cacheKey, { state: 'ok', name });
|
|
140
244
|
} catch {
|
|
141
245
|
lookupNameCache.set(cacheKey, { state: 'err' });
|
|
@@ -144,7 +248,7 @@ function useLookupName(referenceTo: string | undefined, value: unknown): string
|
|
|
144
248
|
})();
|
|
145
249
|
|
|
146
250
|
lookupNameCache.set(cacheKey, { state: 'pending', promise });
|
|
147
|
-
}, [cacheKey, isResolvable, referenceTo, value, dataSource]);
|
|
251
|
+
}, [cacheKey, isResolvable, referenceTo, value, displayField, dataSource]);
|
|
148
252
|
|
|
149
253
|
if (!isResolvable) return undefined;
|
|
150
254
|
const entry = lookupNameCache.get(cacheKey);
|
|
@@ -156,14 +260,28 @@ function useLookupName(referenceTo: string | undefined, value: unknown): string
|
|
|
156
260
|
* default when no I18nProvider is available or when the key is missing.
|
|
157
261
|
*/
|
|
158
262
|
function useFieldLabel() {
|
|
263
|
+
// useObjectTranslation is provider-safe (its context read is optional and
|
|
264
|
+
// react-i18next falls back to the global instance), so no try/catch —
|
|
265
|
+
// wrapping a hook call in try/catch violates rules-of-hooks: a throw after
|
|
266
|
+
// some hooks ran would desync hook order on the next render.
|
|
267
|
+
const { t } = useObjectTranslation();
|
|
268
|
+
return (key: string, fallback: string) => {
|
|
269
|
+
const v = t(key);
|
|
270
|
+
return !v || v === key ? fallback : v;
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Raw translate fn (with interpolation params) for cell-level strings, or
|
|
276
|
+
* undefined when no I18nProvider is mounted — callers keep their English
|
|
277
|
+
* fallback in that case.
|
|
278
|
+
*/
|
|
279
|
+
function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) => string) | undefined {
|
|
159
280
|
try {
|
|
160
281
|
const { t } = useObjectTranslation();
|
|
161
|
-
return (key: string,
|
|
162
|
-
const v = t(key);
|
|
163
|
-
return !v || v === key ? fallback : v;
|
|
164
|
-
};
|
|
282
|
+
return t as (key: string, params?: Record<string, unknown>) => string;
|
|
165
283
|
} catch {
|
|
166
|
-
return
|
|
284
|
+
return undefined;
|
|
167
285
|
}
|
|
168
286
|
}
|
|
169
287
|
|
|
@@ -361,14 +479,43 @@ export function humanizeLabel(value: string): string {
|
|
|
361
479
|
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
362
480
|
}
|
|
363
481
|
|
|
482
|
+
/** Options shared by {@link formatDate} / {@link formatRelativeDate}. */
|
|
483
|
+
export interface DateDisplayOptions {
|
|
484
|
+
dueLike?: boolean;
|
|
485
|
+
/** BCP-47 display locale (ADR-0053 tenant default); falls back to the runtime locale. */
|
|
486
|
+
locale?: string;
|
|
487
|
+
/** i18n translate fn for phrases `Intl` can't produce (the "Overdue Nd" wording). */
|
|
488
|
+
t?: (key: string, params?: Record<string, unknown>) => string;
|
|
489
|
+
}
|
|
490
|
+
|
|
364
491
|
/**
|
|
365
|
-
*
|
|
492
|
+
* Localized day-granularity relative phrase ("Tomorrow", "3 days ago", "明天",
|
|
493
|
+
* "3天前"), sentence-cased for locales whose `Intl` output starts lowercase.
|
|
494
|
+
*/
|
|
495
|
+
function formatRelativeDays(diffDays: number, locale?: string): string {
|
|
496
|
+
try {
|
|
497
|
+
const phrase = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diffDays, 'day');
|
|
498
|
+
return phrase.charAt(0).toUpperCase() + phrase.slice(1);
|
|
499
|
+
} catch {
|
|
500
|
+
// Invalid locale tag — degrade to English rather than crash the cell.
|
|
501
|
+
if (diffDays === 0) return 'Today';
|
|
502
|
+
if (diffDays === 1) return 'Tomorrow';
|
|
503
|
+
if (diffDays === -1) return 'Yesterday';
|
|
504
|
+
return diffDays > 0 ? `In ${diffDays} days` : `${Math.abs(diffDays)} days ago`;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
|
|
510
|
+
* localized via `Intl.RelativeTimeFormat` (objectstack-ai/framework#3040).
|
|
366
511
|
*
|
|
367
512
|
* `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
|
|
368
513
|
* isn't overdue, only a past due/deadline-semantic field is. Non-due-like
|
|
369
|
-
* past dates render as "
|
|
514
|
+
* past dates render as plain "N days ago" instead. The overdue phrase has no
|
|
515
|
+
* `Intl` equivalent, so it resolves through `options.t` (key
|
|
516
|
+
* `fields.relativeDate.overdue`) with an English fallback.
|
|
370
517
|
*/
|
|
371
|
-
export function formatRelativeDate(value: string | Date | number, options?:
|
|
518
|
+
export function formatRelativeDate(value: string | Date | number, options?: DateDisplayOptions): string {
|
|
372
519
|
if (value === null || value === undefined || value === '') return '—';
|
|
373
520
|
const date = value instanceof Date ? value : new Date(value as any);
|
|
374
521
|
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
@@ -379,22 +526,22 @@ export function formatRelativeDate(value: string | Date | number, options?: { du
|
|
|
379
526
|
const diffMs = startOfDate.getTime() - startOfToday.getTime();
|
|
380
527
|
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
381
528
|
|
|
382
|
-
|
|
383
|
-
if (diffDays
|
|
384
|
-
|
|
385
|
-
if (diffDays < -1) {
|
|
529
|
+
// Beyond the ±7-day window, fall back to the absolute (already localized) form.
|
|
530
|
+
if (diffDays < -7 || diffDays > 7) return formatDate(date, undefined, options);
|
|
531
|
+
|
|
532
|
+
if (diffDays < -1 && options?.dueLike) {
|
|
386
533
|
const absDays = Math.abs(diffDays);
|
|
387
|
-
|
|
388
|
-
|
|
534
|
+
const key = 'fields.relativeDate.overdue';
|
|
535
|
+
const translated = options.t?.(key, { count: absDays });
|
|
536
|
+
return translated && translated !== key ? translated : `Overdue ${absDays}d`;
|
|
389
537
|
}
|
|
390
|
-
|
|
391
|
-
return formatDate(date);
|
|
538
|
+
return formatRelativeDays(diffDays, options?.locale);
|
|
392
539
|
}
|
|
393
540
|
|
|
394
541
|
/**
|
|
395
542
|
* Format date value
|
|
396
543
|
*/
|
|
397
|
-
export function formatDate(value: string | Date | number, style?: string, options?:
|
|
544
|
+
export function formatDate(value: string | Date | number, style?: string, options?: DateDisplayOptions): string {
|
|
398
545
|
if (value === null || value === undefined || value === '') return '—';
|
|
399
546
|
const date = value instanceof Date ? value : new Date(value as any);
|
|
400
547
|
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
@@ -417,7 +564,7 @@ export function formatDate(value: string | Date | number, style?: string, option
|
|
|
417
564
|
// verbose "2026年7月21日" form crowds cards and table cells. Past- /
|
|
418
565
|
// future-year dates keep the year so users can disambiguate.
|
|
419
566
|
const isCurrentYear = date.getFullYear() === new Date().getFullYear();
|
|
420
|
-
return date.toLocaleDateString(
|
|
567
|
+
return date.toLocaleDateString(options?.locale, {
|
|
421
568
|
year: isCurrentYear ? undefined : 'numeric',
|
|
422
569
|
month: 'short',
|
|
423
570
|
day: 'numeric',
|
|
@@ -482,14 +629,16 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
482
629
|
* Currency field cell renderer
|
|
483
630
|
*/
|
|
484
631
|
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
632
|
+
// Hooks before the empty-value early return — a value flipping between
|
|
633
|
+
// null and set must not change the hook count between renders.
|
|
634
|
+
const { currency: tenantCurrency } = useLocalization();
|
|
485
635
|
if (value == null) return <EmptyValue />;
|
|
486
|
-
|
|
636
|
+
|
|
487
637
|
const safe = coerceToSafeValue(value);
|
|
488
638
|
// Resolve the display currency via the shared precedence: field `currency` →
|
|
489
639
|
// `currencyConfig.defaultCurrency` → the tenant default (ADR-0053). When none
|
|
490
640
|
// is known, render a plain number — never a guessed symbol (silently assuming
|
|
491
641
|
// USD mis-displays non-USD orgs, e.g. RMB amounts shown as $).
|
|
492
|
-
const { currency: tenantCurrency } = useLocalization();
|
|
493
642
|
const currency = resolveFieldCurrency(field as any, tenantCurrency);
|
|
494
643
|
const num = Number(safe);
|
|
495
644
|
const formatted = !isNaN(num)
|
|
@@ -598,6 +747,8 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
598
747
|
* Date field cell renderer
|
|
599
748
|
*/
|
|
600
749
|
export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
750
|
+
const { locale } = useLocalization();
|
|
751
|
+
const t = useFieldTranslate();
|
|
601
752
|
if (!value) return <EmptyValue />;
|
|
602
753
|
const safe = coerceToSafeValue(value);
|
|
603
754
|
const dateField = field as any;
|
|
@@ -610,7 +761,7 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
610
761
|
const dueLike =
|
|
611
762
|
dateField?.dueLike === true ||
|
|
612
763
|
/(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName);
|
|
613
|
-
const formatted = formatDate(safe as string | Date, style, { dueLike });
|
|
764
|
+
const formatted = formatDate(safe as string | Date, style, { dueLike, locale, t });
|
|
614
765
|
|
|
615
766
|
const date = safe != null ? new Date(safe as string | number) : null;
|
|
616
767
|
const isValidDate = date !== null && !isNaN(date.getTime());
|
|
@@ -946,13 +1097,18 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
946
1097
|
}
|
|
947
1098
|
|
|
948
1099
|
const colorClasses = getBadgeColorClasses(option?.color, val);
|
|
1100
|
+
// max-w-full + inner truncate: in bounded containers (detail highlight
|
|
1101
|
+
// strip columns, grid cells) an overlong label used to clip mid-glyph at
|
|
1102
|
+
// the container edge; now the badge shrinks and ellipsizes, with the full
|
|
1103
|
+
// label on hover.
|
|
949
1104
|
return (
|
|
950
1105
|
<Badge
|
|
951
1106
|
key={key}
|
|
952
1107
|
variant="outline"
|
|
953
|
-
className={colorClasses}
|
|
1108
|
+
className={cn('max-w-full min-w-0', colorClasses)}
|
|
1109
|
+
title={label}
|
|
954
1110
|
>
|
|
955
|
-
{label}
|
|
1111
|
+
<span className="truncate">{label}</span>
|
|
956
1112
|
</Badge>
|
|
957
1113
|
);
|
|
958
1114
|
};
|
|
@@ -973,11 +1129,12 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
973
1129
|
* Email field cell renderer
|
|
974
1130
|
*/
|
|
975
1131
|
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1132
|
+
// Hooks before the empty-value early return (rules-of-hooks).
|
|
1133
|
+
const label = useFieldLabel();
|
|
1134
|
+
const [copied, setCopied] = React.useState(false);
|
|
976
1135
|
if (!value) return <EmptyValue />;
|
|
977
1136
|
|
|
978
|
-
const label = useFieldLabel();
|
|
979
1137
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
980
|
-
const [copied, setCopied] = React.useState(false);
|
|
981
1138
|
|
|
982
1139
|
const handleCopy = (e: React.MouseEvent) => {
|
|
983
1140
|
e.stopPropagation();
|
|
@@ -1047,11 +1204,12 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
|
|
|
1047
1204
|
* Phone field cell renderer
|
|
1048
1205
|
*/
|
|
1049
1206
|
export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1207
|
+
// Hooks before the empty-value early return (rules-of-hooks).
|
|
1208
|
+
const label = useFieldLabel();
|
|
1209
|
+
const [copied, setCopied] = React.useState(false);
|
|
1050
1210
|
if (!value) return <EmptyValue />;
|
|
1051
1211
|
|
|
1052
|
-
const label = useFieldLabel();
|
|
1053
1212
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
1054
|
-
const [copied, setCopied] = React.useState(false);
|
|
1055
1213
|
|
|
1056
1214
|
const handleCopy = (e: React.MouseEvent) => {
|
|
1057
1215
|
e.stopPropagation();
|
|
@@ -1167,6 +1325,11 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
1167
1325
|
* 3. Fetch-on-demand: when the value is a primitive ID and `field.reference_to`
|
|
1168
1326
|
* is known, resolve via dataSource and show the related record's display name.
|
|
1169
1327
|
* Falls back to a muted placeholder while pending and on failure.
|
|
1328
|
+
*
|
|
1329
|
+
* Record → name resolution (1 and 3) goes through the referenced object's
|
|
1330
|
+
* schema when the data source exposes it (`displayField` → nameField/titleFormat
|
|
1331
|
+
* → derivation, see {@link resolveLookupRecordName}), so the chip and the
|
|
1332
|
+
* picker agree (issue #2357).
|
|
1170
1333
|
*/
|
|
1171
1334
|
export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
1172
1335
|
// ObjectStack object metadata uses `reference` for the lookup target while the
|
|
@@ -1178,6 +1341,19 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1178
1341
|
(field as { reference_to?: string }).reference_to ||
|
|
1179
1342
|
(field as { reference?: string }).reference;
|
|
1180
1343
|
|
|
1344
|
+
// Explicit author-chosen display field on the lookup — beats every resolver.
|
|
1345
|
+
// ObjectGrid forwards `display_field` on the column meta (RELATIONAL_META_KEYS)
|
|
1346
|
+
// the same way it forwards `reference` (#2926 ⑧).
|
|
1347
|
+
const displayField =
|
|
1348
|
+
(field as { display_field?: string }).display_field ||
|
|
1349
|
+
(field as { displayField?: string }).displayField ||
|
|
1350
|
+
(field as { reference_field?: string }).reference_field ||
|
|
1351
|
+
undefined;
|
|
1352
|
+
|
|
1353
|
+
// Referenced object's schema (nameField / titleFormat) so expanded records
|
|
1354
|
+
// resolve through the same unified resolver as the picker (issue #2357).
|
|
1355
|
+
const refSchema = useRefObjectSchema(referenceTo);
|
|
1356
|
+
|
|
1181
1357
|
// Pick the FIRST primitive id we see (for arrays, only the first one is auto-resolved
|
|
1182
1358
|
// to keep the cell cheap; multi-value lookups should generally be expanded server-side).
|
|
1183
1359
|
const primaryPrimitiveId = (() => {
|
|
@@ -1199,7 +1375,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1199
1375
|
})();
|
|
1200
1376
|
|
|
1201
1377
|
// Always call the hook (rules of hooks). It safely no-ops when inputs are missing.
|
|
1202
|
-
const resolvedName = useLookupName(referenceTo, primaryPrimitiveId);
|
|
1378
|
+
const resolvedName = useLookupName(referenceTo, primaryPrimitiveId, displayField);
|
|
1203
1379
|
|
|
1204
1380
|
if (value == null || value === '') return <EmptyValue />;
|
|
1205
1381
|
|
|
@@ -1209,15 +1385,18 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1209
1385
|
if (typeof value === 'string') {
|
|
1210
1386
|
const s = value.trim();
|
|
1211
1387
|
if (s.startsWith('{') && s.endsWith('}')) {
|
|
1388
|
+
// Compute inside try, render outside — constructing JSX in a try/catch
|
|
1389
|
+
// doesn't catch its render errors anyway (react-hooks/error-boundaries).
|
|
1390
|
+
let parsedDisplay = '';
|
|
1212
1391
|
try {
|
|
1213
1392
|
const parsed = JSON.parse(s) as Record<string, unknown>;
|
|
1214
1393
|
if (parsed && typeof parsed === 'object') {
|
|
1215
|
-
|
|
1216
|
-
|
|
1394
|
+
parsedDisplay =
|
|
1395
|
+
resolveLookupRecordName(parsed, refSchema, displayField) ||
|
|
1217
1396
|
String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
|
|
1218
|
-
if (display) return <span className="truncate">{display}</span>;
|
|
1219
1397
|
}
|
|
1220
1398
|
} catch { /* not JSON — fall through to normal resolution */ }
|
|
1399
|
+
if (parsedDisplay) return <span className="truncate">{parsedDisplay}</span>;
|
|
1221
1400
|
}
|
|
1222
1401
|
}
|
|
1223
1402
|
|
|
@@ -1225,7 +1404,8 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1225
1404
|
// (e.g. { id, name }). Render its display name directly — no fetch needed.
|
|
1226
1405
|
if (!Array.isArray(value) && typeof value === 'object') {
|
|
1227
1406
|
const obj = value as Record<string, unknown>;
|
|
1228
|
-
const display =
|
|
1407
|
+
const display =
|
|
1408
|
+
resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
|
|
1229
1409
|
if (display) {
|
|
1230
1410
|
return <span className="truncate">{display}</span>;
|
|
1231
1411
|
}
|
|
@@ -1259,7 +1439,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1259
1439
|
let label: string;
|
|
1260
1440
|
let muted = false;
|
|
1261
1441
|
if (item != null && typeof item === 'object') {
|
|
1262
|
-
label =
|
|
1442
|
+
label = resolveLookupRecordName(item as Record<string, unknown>, refSchema, displayField) || String((item as any).id || (item as any)._id || '[Object]');
|
|
1263
1443
|
} else {
|
|
1264
1444
|
const r = resolveLabel(item);
|
|
1265
1445
|
label = r.text;
|
|
@@ -1285,7 +1465,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1285
1465
|
|
|
1286
1466
|
if (typeof value === 'object' && value !== null) {
|
|
1287
1467
|
const label =
|
|
1288
|
-
|
|
1468
|
+
resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
|
|
1289
1469
|
String((value as any).id || (value as any)._id || '[Object]');
|
|
1290
1470
|
return <span className="truncate">{label}</span>;
|
|
1291
1471
|
}
|
|
@@ -104,6 +104,41 @@ describe('Standard Field Widgets', () => {
|
|
|
104
104
|
);
|
|
105
105
|
expect(container.textContent).toBe('99.9');
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
it('should surface metadata min/max on the input (objectui#2572)', () => {
|
|
109
|
+
render(
|
|
110
|
+
<NumberField
|
|
111
|
+
value={5}
|
|
112
|
+
onChange={mockOnChange}
|
|
113
|
+
field={{ type: 'number', min: 0, max: 100 } as any}
|
|
114
|
+
/>
|
|
115
|
+
);
|
|
116
|
+
const input = screen.getByRole('spinbutton');
|
|
117
|
+
expect(input).toHaveAttribute('min', '0');
|
|
118
|
+
expect(input).toHaveAttribute('max', '100');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should step by 1 for scale: 0 (whole numbers), not "any"', () => {
|
|
122
|
+
render(
|
|
123
|
+
<NumberField
|
|
124
|
+
value={5}
|
|
125
|
+
onChange={mockOnChange}
|
|
126
|
+
field={{ type: 'number', scale: 0 } as any}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should honor an explicit metadata step over the scale-derived one', () => {
|
|
133
|
+
render(
|
|
134
|
+
<NumberField
|
|
135
|
+
value={5}
|
|
136
|
+
onChange={mockOnChange}
|
|
137
|
+
field={{ type: 'number', scale: 2, step: 5 } as any}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '5');
|
|
141
|
+
});
|
|
107
142
|
});
|
|
108
143
|
|
|
109
144
|
describe('TextField', () => {
|
|
@@ -81,6 +81,11 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
|
|
|
81
81
|
placeholder={currencyField?.placeholder || '0.00'}
|
|
82
82
|
disabled={readonly || props.disabled}
|
|
83
83
|
className={`${symbol ? 'pl-8' : ''} ${className || ''}`}
|
|
84
|
+
// Surface the field's declared range (e.g. `min: 0` on a budget) so the
|
|
85
|
+
// browser's spinner/keyboard affordances respect it (objectui#2572);
|
|
86
|
+
// server-side validation still owns enforcement.
|
|
87
|
+
min={typeof currencyField?.min === 'number' ? currencyField.min : undefined}
|
|
88
|
+
max={typeof currencyField?.max === 'number' ? currencyField.max : undefined}
|
|
84
89
|
step={Math.pow(10, -precision).toFixed(precision)}
|
|
85
90
|
aria-invalid={!!errorMessage}
|
|
86
91
|
/>
|