@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/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(referenceTo: string | undefined, value: unknown): string | undefined {
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
- const cacheKey = isResolvable ? `${referenceTo}:${String(value)}` : '';
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
- const name = pickRecordDisplayName(record);
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, fallback: 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 (_k: string, fallback: string) => fallback;
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
- * Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
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 "Nd ago" instead.
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?: { dueLike?: boolean }): string {
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
- if (diffDays === 0) return 'Today';
383
- if (diffDays === 1) return 'Tomorrow';
384
- if (diffDays === -1) return 'Yesterday';
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
- if (absDays <= 7) return options?.dueLike ? `Overdue ${absDays}d` : `${absDays}d ago`;
388
- return formatDate(date);
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
- if (diffDays > 1 && diffDays <= 7) return `In ${diffDays} days`;
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?: { dueLike?: boolean }): string {
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(undefined, {
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
- const display =
1216
- pickRecordDisplayName(parsed) ||
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 = pickRecordDisplayName(obj) || String(obj.id || obj._id || '');
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 = pickRecordDisplayName(item as Record<string, unknown>) || String((item as any).id || (item as any)._id || '[Object]');
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
- pickRecordDisplayName(value as Record<string, unknown>) ||
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
  />