@object-ui/fields 4.0.7 → 4.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/index.cjs +3 -3
- package/dist/index.js +1220 -1038
- package/dist/packages/fields/src/index.d.ts +19 -3
- package/dist/packages/fields/src/widgets/RecordPickerDialog.d.ts +9 -1
- package/package.json +9 -9
- package/src/index.tsx +176 -33
- package/src/widgets/CurrencyField.tsx +33 -11
- package/src/widgets/LookupField.tsx +132 -9
- package/src/widgets/RecordPickerDialog.tsx +37 -1
|
@@ -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
|
|
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?.
|
|
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]?.
|
|
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?.
|
|
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?.
|
|
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) => {
|