@object-ui/fields 4.0.6 → 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.
@@ -30,17 +30,65 @@ const LOOKUP_PAGE_SIZE = 50;
30
30
  */
31
31
  const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
32
32
 
33
+ /**
34
+ * Render a record title from a `titleFormat` template (e.g. `{full_name}` or
35
+ * `{case_number} - {subject}`). When a templated key resolves to an empty
36
+ * value, the surrounding separator (`-/|·,:` plus em/en dashes) is stripped
37
+ * so we never produce orphan glyphs like `" - foo"`.
38
+ *
39
+ * Mirrors the implementation in `@object-ui/plugin-detail`'s
40
+ * `resolveDisplayTitle` and `@object-ui/plugin-calendar`'s event-title
41
+ * renderer so labels stay consistent across the product.
42
+ */
43
+ function formatRecordTitle(record: any, titleFormat: string): string | null {
44
+ if (!record || typeof record !== 'object' || !titleFormat) return null;
45
+ const EMPTY = '\u0000';
46
+ const SEP = '[-\\u2013\\u2014|/·,:]';
47
+ let any = false;
48
+ const raw = titleFormat.replace(/\{([^{}]+)\}/g, (_m, key) => {
49
+ const v = (record as any)[key.trim()];
50
+ if (v !== null && v !== undefined && v !== '') {
51
+ any = true;
52
+ return String(v);
53
+ }
54
+ return EMPTY;
55
+ });
56
+ if (!any) return null;
57
+ const out = raw
58
+ .replace(new RegExp(`\\s*${SEP}\\s*${EMPTY}`, 'g'), '')
59
+ .replace(new RegExp(`${EMPTY}\\s*${SEP}\\s*`, 'g'), '')
60
+ .replace(new RegExp(EMPTY, 'g'), '')
61
+ .replace(/\s+/g, ' ')
62
+ .trim();
63
+ return out || null;
64
+ }
65
+
33
66
  /**
34
67
  * Map a raw record to a LookupOption using a display field and an id field.
68
+ *
69
+ * When `titleFormat` is supplied (typically derived from the referenced
70
+ * object's schema), the label is rendered via the template so users see a
71
+ * human-readable name (e.g. `"Acme - John Doe"`) instead of falling all the
72
+ * way through to the raw record id.
35
73
  */
36
74
  function recordToOption(
37
75
  record: any,
38
76
  displayField: string,
39
77
  idField: string,
40
78
  descriptionField?: string,
79
+ titleFormat?: string | null,
41
80
  ): LookupOption {
42
81
  const val = record[idField] ?? record.id ?? record._id;
43
- const label = record[displayField] ?? record.label ?? record.name ?? String(val);
82
+ const templated = titleFormat ? formatRecordTitle(record, titleFormat) : null;
83
+ const label =
84
+ templated ??
85
+ record[displayField] ??
86
+ record.label ??
87
+ record.name ??
88
+ record.full_name ??
89
+ record.title ??
90
+ record.subject ??
91
+ String(val);
44
92
  const description = descriptionField ? record[descriptionField] : undefined;
45
93
  return { value: val, label: String(label), description, ...record };
46
94
  }
@@ -191,6 +239,29 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
191
239
 
192
240
  const hasDataSource = dataSource != null && typeof dataSource.find === 'function' && !!referenceTo;
193
241
 
242
+ // Fetch the referenced object's schema so we can render option labels via
243
+ // its `titleFormat` template (e.g. `{full_name}`, `{case_number} - {subject}`).
244
+ // Without this the label fell back to a non-existent `name` field and
245
+ // ultimately to the raw record id.
246
+ const [refObjectSchema, setRefObjectSchema] = useState<any>(null);
247
+ useEffect(() => {
248
+ if (!dataSource || !referenceTo) return;
249
+ const getSchema = (dataSource as any).getObjectSchema;
250
+ if (typeof getSchema !== 'function') return;
251
+ let alive = true;
252
+ Promise.resolve(getSchema.call(dataSource, referenceTo))
253
+ .then((s: any) => { if (alive) setRefObjectSchema(s); })
254
+ .catch(() => { /* fall back to displayField chain */ });
255
+ return () => { alive = false; };
256
+ }, [dataSource, referenceTo]);
257
+
258
+ const refTitleFormat: string | null = useMemo(() => {
259
+ const raw = refObjectSchema?.titleFormat;
260
+ if (typeof raw === 'string') return raw;
261
+ if (raw && typeof raw === 'object' && typeof raw.source === 'string') return raw.source;
262
+ return null;
263
+ }, [refObjectSchema]);
264
+
194
265
  // Optional create-new callback
195
266
  const onCreateNew: ((searchQuery: string) => void) | undefined =
196
267
  (props as any).onCreateNew ?? lookupField?.onCreateNew;
@@ -256,7 +327,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
256
327
 
257
328
  const result = await dataSource.find(referenceTo, params);
258
329
  const records: any[] = result?.data ?? result ?? [];
259
- const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField));
330
+ const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField, refTitleFormat));
260
331
 
261
332
  setFetchedOptions(mapped);
262
333
  setTotalCount(result?.total ?? records.length);
@@ -268,7 +339,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
268
339
  setLoading(false);
269
340
  }
270
341
  },
271
- [dataSource, referenceTo, displayField, idField, descriptionField, dependenciesMissing, dependsOn, resolvedDependentValues],
342
+ [dataSource, referenceTo, displayField, idField, descriptionField, refTitleFormat, dependenciesMissing, dependsOn, resolvedDependentValues],
272
343
  );
273
344
 
274
345
  // Re-fetch when dependent values change while the picker is open. This keeps
@@ -329,6 +400,57 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
329
400
  };
330
401
  }, []);
331
402
 
403
+ /**
404
+ * Hydrate the picker's display when the field already has a value (e.g.
405
+ * edit-mode load, prefill via query-string from a related-list "+ New")
406
+ * but no option resolves it yet. Fetches the referenced record(s) via
407
+ * the DataSource and caches them in `pickerResolvedRecords` so the chip
408
+ * shows a friendly label instead of an empty placeholder.
409
+ */
410
+ useEffect(() => {
411
+ if (!hasDataSource || !dataSource || !referenceTo) return;
412
+ const ids: any[] = multiple
413
+ ? Array.isArray(value) ? value : []
414
+ : value != null && value !== '' ? [value] : [];
415
+ if (!ids.length) return;
416
+ // Only fetch records we haven't resolved yet.
417
+ const unresolved = ids.filter((v) => !findOption(v));
418
+ if (!unresolved.length) return;
419
+
420
+ let cancelled = false;
421
+ (async () => {
422
+ try {
423
+ const fetched: LookupOption[] = [];
424
+ for (const id of unresolved) {
425
+ if (typeof (dataSource as any).findOne === 'function') {
426
+ const rec = await (dataSource as any).findOne(referenceTo, id);
427
+ if (rec) fetched.push(recordToOption(rec, displayField, idField, descriptionField, refTitleFormat));
428
+ } else {
429
+ const res = await dataSource.find(referenceTo, {
430
+ $filter: { [idField]: id },
431
+ $top: 1,
432
+ } as QueryParams);
433
+ const rows = res?.data ?? res ?? [];
434
+ if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, descriptionField, refTitleFormat));
435
+ }
436
+ }
437
+ if (!cancelled && fetched.length) {
438
+ setPickerResolvedRecords((prev) => {
439
+ const map = new Map(prev.map((o) => [o.value, o]));
440
+ for (const o of fetched) map.set(o.value, o);
441
+ return Array.from(map.values());
442
+ });
443
+ }
444
+ } catch {
445
+ // Ignore — chip will fall back to showing the raw id.
446
+ }
447
+ })();
448
+ return () => {
449
+ cancelled = true;
450
+ };
451
+ // eslint-disable-next-line react-hooks/exhaustive-deps
452
+ }, [value, hasDataSource, referenceTo, displayField, idField, descriptionField, multiple]);
453
+
332
454
  // Get selected option(s) — check static, fetched, and picker-resolved options
333
455
  const findOption = useCallback(
334
456
  (v: any): LookupOption | undefined => {
@@ -377,10 +499,10 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
377
499
  // findOption can resolve display labels after the dialog closes.
378
500
  const handlePickerSelectRecords = useCallback(
379
501
  (records: any[]) => {
380
- const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField));
502
+ const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField, refTitleFormat));
381
503
  setPickerResolvedRecords(mapped);
382
504
  },
383
- [displayField, idField, descriptionField],
505
+ [displayField, idField, descriptionField, refTitleFormat],
384
506
  );
385
507
 
386
508
  // Keyboard handler for the search input — arrow keys + Enter
@@ -422,7 +544,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
422
544
  <div className="flex flex-wrap gap-1">
423
545
  {selectedOptions.map((opt, idx) => (
424
546
  <Badge key={idx} variant="outline">
425
- {opt?.[displayField] || opt?.label}
547
+ {opt?.label || opt?.[displayField]}
426
548
  </Badge>
427
549
  ))}
428
550
  </div>
@@ -431,7 +553,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
431
553
 
432
554
  return (
433
555
  <span className="text-sm">
434
- {selectedOptions[0]?.[displayField] || selectedOptions[0]?.label}
556
+ {selectedOptions[0]?.label || selectedOptions[0]?.[displayField]}
435
557
  </span>
436
558
  );
437
559
  }
@@ -447,12 +569,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
447
569
  variant="outline"
448
570
  className="gap-1"
449
571
  >
450
- {opt?.[displayField] || opt?.label}
572
+ {opt?.label || opt?.[displayField]}
451
573
  <button
452
574
  onClick={() => handleRemove(opt?.value)}
453
575
  className="ml-1 hover:text-destructive"
454
576
  type="button"
455
- aria-label={`Remove ${opt?.[displayField] || opt?.label}`}
577
+ aria-label={`Remove ${opt?.label || opt?.[displayField]}`}
456
578
  >
457
579
  <X className="size-3" />
458
580
  </button>
@@ -662,6 +784,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
662
784
  objectName={referenceTo}
663
785
  columns={lookupColumns}
664
786
  displayField={displayField}
787
+ titleFormat={refTitleFormat}
665
788
  idField={idField}
666
789
  pageSize={lookupPageSize}
667
790
  value={value}
@@ -226,6 +226,14 @@ export interface RecordPickerDialogProps {
226
226
  columns?: Array<string | LookupColumnDef>;
227
227
  /** Primary display field (default: 'name') */
228
228
  displayField?: string;
229
+ /**
230
+ * Optional `titleFormat` template (e.g. `"{full_name}"` or
231
+ * `"{case_number} - {subject}"`). When set and the displayField column is
232
+ * auto-inferred, the column renders via the template instead of reading
233
+ * a possibly-missing field. Mirrors how DetailView/ObjectCalendar resolve
234
+ * record titles.
235
+ */
236
+ titleFormat?: string | null;
229
237
  /** Record id field (default: 'id') */
230
238
  idField?: string;
231
239
 
@@ -316,6 +324,7 @@ export function RecordPickerDialog({
316
324
  objectName,
317
325
  columns: columnsProp,
318
326
  displayField = 'name',
327
+ titleFormat,
319
328
  idField = 'id',
320
329
  pageSize = DEFAULT_PAGE_SIZE,
321
330
  value,
@@ -668,6 +677,33 @@ export function RecordPickerDialog({
668
677
 
669
678
  // Get display value for a cell — type-aware rendering when cellRenderer is provided
670
679
  const renderCellContent = useCallback((record: any, col: LookupColumnDef): React.ReactNode => {
680
+ // When the column is the auto-inferred displayField column and the
681
+ // referenced object declares a `titleFormat`, render via the template so
682
+ // users see human-readable names instead of a raw id when the displayField
683
+ // (commonly defaulted to `name`) doesn't exist on the record.
684
+ if (titleFormat && col.field === displayField) {
685
+ const EMPTY = '\u0000';
686
+ const SEP = '[-\\u2013\\u2014|/·,:]';
687
+ let any = false;
688
+ const raw = titleFormat.replace(/\{([^{}]+)\}/g, (_m, key) => {
689
+ const v = (record as any)[key.trim()];
690
+ if (v !== null && v !== undefined && v !== '') {
691
+ any = true;
692
+ return String(v);
693
+ }
694
+ return EMPTY;
695
+ });
696
+ if (any) {
697
+ const out = raw
698
+ .replace(new RegExp(`\\s*${SEP}\\s*${EMPTY}`, 'g'), '')
699
+ .replace(new RegExp(`${EMPTY}\\s*${SEP}\\s*`, 'g'), '')
700
+ .replace(new RegExp(EMPTY, 'g'), '')
701
+ .replace(/\s+/g, ' ')
702
+ .trim();
703
+ if (out) return out;
704
+ }
705
+ }
706
+
671
707
  const val = record[col.field];
672
708
 
673
709
  // Use type-aware renderer when column type and resolver are available
@@ -690,7 +726,7 @@ export function RecordPickerDialog({
690
726
  }
691
727
  if (typeof val === 'boolean') return val ? 'Yes' : 'No';
692
728
  return String(val);
693
- }, [cellRenderer]);
729
+ }, [cellRenderer, titleFormat, displayField]);
694
730
 
695
731
  // Render sort indicator for a column
696
732
  const renderSortIcon = useCallback((field: string) => {