@object-ui/fields 11.2.0 → 11.4.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/dist/FieldEditWidget.d.ts +21 -0
  3. package/dist/FieldEditWidget.test.d.ts +8 -0
  4. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  5. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.js +2155 -1438
  9. package/dist/widgets/LookupField.dependsOn.test.d.ts +8 -0
  10. package/dist/widgets/PeoplePicker.d.ts +63 -0
  11. package/dist/widgets/PeoplePicker.test.d.ts +8 -0
  12. package/dist/widgets/PeoplePickerComponents.test.d.ts +8 -0
  13. package/dist/widgets/PersonRow.d.ts +23 -0
  14. package/dist/widgets/RecordPickerDialog.d.ts +9 -1
  15. package/dist/widgets/SelectionTray.d.ts +27 -0
  16. package/dist/widgets/UserField.d.ts +0 -13
  17. package/dist/widgets/UserFieldPicker.test.d.ts +8 -0
  18. package/dist/widgets/deriveLookupColumns.d.ts +12 -4
  19. package/dist/widgets/personDisplay.d.ts +37 -0
  20. package/dist/widgets/personDisplay.test.d.ts +8 -0
  21. package/dist/widgets/useRecordQuery.d.ts +87 -0
  22. package/dist/widgets/useRecordQuery.test.d.ts +8 -0
  23. package/package.json +11 -11
  24. package/src/FieldEditWidget.test.ts +61 -0
  25. package/src/FieldEditWidget.tsx +146 -0
  26. package/src/__tests__/NumberCellRenderer.test.tsx +60 -0
  27. package/src/__tests__/lookupCellReferenceKey.test.tsx +57 -0
  28. package/src/complex-widgets.test.tsx +241 -0
  29. package/src/index.tsx +33 -4
  30. package/src/widgets/GridField.test.tsx +14 -0
  31. package/src/widgets/GridField.tsx +7 -8
  32. package/src/widgets/LookupField.dependsOn.test.tsx +233 -0
  33. package/src/widgets/LookupField.tsx +397 -199
  34. package/src/widgets/NumberField.tsx +4 -2
  35. package/src/widgets/PeoplePicker.test.tsx +334 -0
  36. package/src/widgets/PeoplePicker.tsx +552 -0
  37. package/src/widgets/PeoplePickerComponents.test.tsx +104 -0
  38. package/src/widgets/PersonRow.tsx +117 -0
  39. package/src/widgets/RecordPickerDialog.tsx +58 -129
  40. package/src/widgets/SelectionTray.tsx +110 -0
  41. package/src/widgets/UserField.tsx +20 -1
  42. package/src/widgets/UserFieldPicker.test.tsx +78 -0
  43. package/src/widgets/deriveLookupColumns.test.ts +25 -0
  44. package/src/widgets/deriveLookupColumns.ts +24 -10
  45. package/src/widgets/personDisplay.test.ts +113 -0
  46. package/src/widgets/personDisplay.ts +92 -0
  47. package/src/widgets/useRecordQuery.test.ts +174 -0
  48. package/src/widgets/useRecordQuery.ts +264 -0
@@ -3,6 +3,9 @@ import { cn,
3
3
  Button,
4
4
  Input,
5
5
  Badge,
6
+ Avatar,
7
+ AvatarFallback,
8
+ AvatarImage,
6
9
  Popover,
7
10
  PopoverTrigger,
8
11
  PopoverContent, EmptyValue } from '@object-ui/components';
@@ -11,10 +14,14 @@ import { FieldWidgetProps } from './types';
11
14
  import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
12
15
  import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
13
16
  import type { RecordPickerFilterColumn } from './RecordPickerDialog';
17
+ import { PeoplePicker } from './PeoplePicker';
18
+ import { useRecordQuery } from './useRecordQuery';
14
19
  import { deriveLookupColumns } from './deriveLookupColumns';
20
+ import { getRecordDisplayName } from '@object-ui/core';
15
21
  import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
22
+ import { getPersonInitials } from './personDisplay';
16
23
  import { getCellRendererResolver } from './_cell-renderer-bridge';
17
- import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
24
+ import { SchemaRendererContext as ImportedSchemaRendererContext, useAction, useHasActionProvider } from '@object-ui/react';
18
25
  import { useFieldTranslation } from './useFieldTranslation';
19
26
 
20
27
  export interface LookupOption {
@@ -33,6 +40,22 @@ const LOOKUP_PAGE_SIZE = 50;
33
40
  */
34
41
  const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
35
42
 
43
+ /**
44
+ * A relation whose picker should offer inline "create the referenced record" by
45
+ * default. Inline quick-create is a STANDARD capability so a freshly-built app
46
+ * isn't a dead end (an empty required picker → you can create the FIRST related
47
+ * record right here). Platform/system objects are excluded: the user directory
48
+ * (`sys_user` and its bare `user`/`users` aliases) and everything under the
49
+ * `sys_`/`cloud_`/`ai_` namespaces are pre-populated plumbing you must not create
50
+ * from a field picker. A user-authored business entity (customer, pet, book, …)
51
+ * never matches, so it gets the capability.
52
+ */
53
+ const SYSTEM_REFERENCE_RX = /^(sys_|cloud_|ai_)/;
54
+ const USER_DIRECTORY_REFS = new Set(['user', 'users']);
55
+ function isUserFacingReference(reference: string | undefined): boolean {
56
+ return !!reference && !SYSTEM_REFERENCE_RX.test(reference) && !USER_DIRECTORY_REFS.has(reference);
57
+ }
58
+
36
59
  /**
37
60
  * Render a record title from a `titleFormat` template (e.g. `{full_name}` or
38
61
  * `{case_number} - {subject}`). When a templated key resolves to an empty
@@ -69,10 +92,17 @@ function formatRecordTitle(record: any, titleFormat: string): string | null {
69
92
  /**
70
93
  * Map a raw record to a LookupOption using a display field and an id field.
71
94
  *
72
- * When `titleFormat` is supplied (typically derived from the referenced
73
- * object's schema), the label is rendered via the template so users see a
74
- * human-readable name (e.g. `"Acme - John Doe"`) instead of falling all the
75
- * way through to the raw record id.
95
+ * Label precedence (ADR-0079):
96
+ * 1. `titleFormat` template (when supplied, derived from the referenced
97
+ * object's schema) — e.g. `"Acme - John Doe"`.
98
+ * 2. the explicit `displayField` value on the record.
99
+ * 3. the unified `@object-ui/core#getRecordDisplayName` against the referenced
100
+ * object's schema (`objectDef`) — adds `displayNameField` + type-aware
101
+ * field derivation, so a lookup to an object whose name lives in e.g.
102
+ * `activity_name` resolves a real name instead of the raw id. We stop short
103
+ * of the resolver's `Record #<id>` floor here so the chip still falls
104
+ * through to the bare id when nothing nameable exists.
105
+ * 4. the legacy hard-coded name list, then the raw id.
76
106
  */
77
107
  function recordToOption(
78
108
  record: any,
@@ -80,22 +110,57 @@ function recordToOption(
80
110
  idField: string,
81
111
  descriptionField?: string,
82
112
  titleFormat?: string | null,
113
+ objectDef?: any,
83
114
  ): LookupOption {
84
- const val = record[idField] ?? record.id ?? record._id;
115
+ const val = record[idField] ?? record.id ?? record._id ?? record.externalId;
85
116
  const templated = titleFormat ? formatRecordTitle(record, titleFormat) : null;
117
+
118
+ // Object-level resolver fallback (displayNameField + derivation), excluding
119
+ // its id floor so we don't shadow the explicit `String(val)` tail.
120
+ let unified: string | undefined;
121
+ if (objectDef) {
122
+ const resolved = getRecordDisplayName(objectDef, record);
123
+ const id = record?.id ?? record?._id;
124
+ const isFloor =
125
+ resolved === 'Untitled' ||
126
+ (id !== null && id !== undefined && resolved === `Record #${id}`);
127
+ if (!isFloor) unified = resolved;
128
+ }
129
+
86
130
  const label =
87
131
  templated ??
88
132
  record[displayField] ??
133
+ unified ??
89
134
  record.label ??
90
135
  record.name ??
91
136
  record.full_name ??
92
137
  record.title ??
93
138
  record.subject ??
139
+ record.externalId ??
94
140
  String(val);
95
141
  const description = descriptionField ? record[descriptionField] : undefined;
96
142
  return { value: val, label: String(label), description, ...record };
97
143
  }
98
144
 
145
+ /**
146
+ * A reference value can arrive JSON-encoded — e.g. an unresolved external-id
147
+ * reference `'{"externalId":"Website Relaunch"}'`. Parse such a string into its
148
+ * object form so the inline editor resolves it through the same path as a
149
+ * server-`$expand`ed record. Returns null for anything that isn't a JSON object
150
+ * string. Mirrors the read cell (`LookupCellRenderer`) so the two stay aligned.
151
+ */
152
+ function parseReferenceObjectString(v: any): Record<string, any> | null {
153
+ if (typeof v !== 'string') return null;
154
+ const s = v.trim();
155
+ if (!s.startsWith('{') || !s.endsWith('}')) return null;
156
+ try {
157
+ const parsed = JSON.parse(s);
158
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
99
164
  /**
100
165
  * Map a LookupColumnDef.type to a filter input type for the filter bar.
101
166
  * Returns undefined if the field type is not filterable.
@@ -128,16 +193,12 @@ function mapFieldTypeToFilterType(
128
193
  */
129
194
  export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
130
195
  const [isOpen, setIsOpen] = useState(false);
131
- const [searchQuery, setSearchQuery] = useState('');
132
196
  const { t } = useFieldTranslation();
133
197
  const listboxId = React.useId();
134
198
 
135
- // Dynamic data loading state
136
- const [fetchedOptions, setFetchedOptions] = useState<LookupOption[]>([]);
137
- const [loading, setLoading] = useState(false);
138
- const [error, setError] = useState<string | null>(null);
139
- const [totalCount, setTotalCount] = useState(0);
140
- const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
199
+ // Create-new error is local; the popover's fetch state (search/loading/error/
200
+ // total/options) is sourced from the shared useRecordQuery kernel below.
201
+ const [createError, setCreateError] = useState<string | null>(null);
141
202
 
142
203
  // Records selected via RecordPickerDialog (Level 2).
143
204
  // Stored as LookupOption so that findOption can resolve display labels
@@ -167,15 +228,28 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
167
228
  const idField = fieldMeta?.id_field || 'id';
168
229
  // ObjectStack convention uses `reference`; types define `reference_to` — support both
169
230
  const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
170
- // Opt-in inline quick-create: when set, an empty/zero-result picker offers to
171
- // create a record from the typed text via dataSource.create.
172
- const allowCreate: boolean = !!(fieldMeta?.allow_create ?? fieldMeta?.allowCreate);
231
+ // Inline quick-create — a STANDARD capability, default ON for user-facing
232
+ // relations: an empty/zero-result picker offers to create the referenced
233
+ // record (opening its create form; see handleCreateNew) so the first related
234
+ // record can be made right here. An explicit `allowCreate` (either casing)
235
+ // wins — set it `false` to opt a field out; system/user-directory references
236
+ // are excluded from the default (isUserFacingReference).
237
+ const explicitAllowCreate = fieldMeta?.allow_create ?? fieldMeta?.allowCreate;
238
+ const allowCreate: boolean =
239
+ explicitAllowCreate != null ? !!explicitAllowCreate : isUserFacingReference(referenceTo);
173
240
 
174
241
  // Enterprise Record Picker configuration
175
242
  const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns;
176
243
  const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize;
177
244
  const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters;
178
245
 
246
+ // Search-first PeoplePicker opt-in (user fields). When `picker === 'search'`
247
+ // the Level-2 picker is the rich PeoplePicker (avatar rows + selection tray)
248
+ // instead of the classic table dialog. `subtitle`/`avatar_field` drive the rows.
249
+ const pickerVariant: string | undefined = fieldMeta?.picker;
250
+ const subtitleFields: string[] | undefined = fieldMeta?.subtitle;
251
+ const avatarField: string = fieldMeta?.avatar_field ?? fieldMeta?.avatarField ?? 'image';
252
+
179
253
  /**
180
254
  * Dependent lookups — restrict candidates based on values of *other* fields
181
255
  * in the same form. Two shapes are accepted:
@@ -294,7 +368,54 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
294
368
  // State for the full Record Picker dialog (Level 2)
295
369
  const [isPickerOpen, setIsPickerOpen] = useState(false);
296
370
 
371
+ // Dependent-lookup chain as a hard QueryParams.$filter record. Shared by
372
+ // EVERY candidate surface — quick-select popover, Level-2 table picker and
373
+ // the search-first PeoplePicker — so no picker can bypass the cascade
374
+ // (#2215: the table picker used to list the full unfiltered set).
375
+ const dependentFilter = useMemo<Record<string, any> | undefined>(() => {
376
+ const f: Record<string, any> = {};
377
+ for (const { field, param } of dependsOn) {
378
+ const v = resolvedDependentValues[field];
379
+ if (v === undefined || v === null || v === '') continue;
380
+ f[param] = typeof v === 'number' ? v : String(v);
381
+ }
382
+ return Object.keys(f).length > 0 ? f : undefined;
383
+ }, [dependsOn, resolvedDependentValues]);
384
+
297
385
  // Determine which options to display
386
+ // Quick-select popover fetch — the shared record-query kernel (same one the
387
+ // Record Picker dialog and PeoplePicker use). Filter = dependent-lookup chain
388
+ // + base lookupFilters, so the popover matches the full picker.
389
+ const popoverFilter = useMemo<Record<string, any> | undefined>(() => {
390
+ const f: Record<string, any> = {
391
+ ...(lookupFilters && lookupFilters.length > 0 ? lookupFiltersToRecord(lookupFilters) : {}),
392
+ ...(dependentFilter ?? {}),
393
+ };
394
+ return Object.keys(f).length > 0 ? f : undefined;
395
+ }, [dependentFilter, lookupFilters]);
396
+
397
+ const popoverQuery = useRecordQuery({
398
+ dataSource,
399
+ objectName: referenceTo,
400
+ enabled: isOpen && hasDataSource && !dependenciesMissing,
401
+ pageSize: LOOKUP_PAGE_SIZE,
402
+ filter: popoverFilter,
403
+ });
404
+
405
+ // Re-source the popover's fetch state from the kernel; all existing read sites
406
+ // (searchQuery / loading / error / totalCount / fetchedOptions) stay unchanged.
407
+ const searchQuery = popoverQuery.search;
408
+ const loading = popoverQuery.loading;
409
+ const totalCount = popoverQuery.total;
410
+ const error = popoverQuery.error ?? createError;
411
+ const fetchedOptions = useMemo(
412
+ () =>
413
+ popoverQuery.records.map(r =>
414
+ recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema),
415
+ ),
416
+ [popoverQuery.records, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
417
+ );
418
+
298
419
  const allOptions = hasDataSource ? fetchedOptions : staticOptions;
299
420
 
300
421
  // For static options, filter locally based on search
@@ -313,124 +434,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
313
434
  setActiveIndex(-1);
314
435
  }, [filteredOptions.length]);
315
436
 
316
- // Fetch data from DataSource
317
- const fetchLookupData = useCallback(
318
- async (search?: string) => {
319
- if (!dataSource || !referenceTo) return;
320
- // Don't issue a request that ignores configured dependencies.
321
- if (dependenciesMissing) {
322
- setFetchedOptions([]);
323
- setTotalCount(0);
324
- return;
325
- }
326
-
327
- setLoading(true);
328
- setError(null);
329
-
330
- try {
331
- const params: QueryParams = {
332
- $top: LOOKUP_PAGE_SIZE,
333
- };
334
- if (search && search.trim()) {
335
- params.$search = search.trim();
336
- }
337
-
338
- // Build a dependent-lookup filter chain: AND of `param eq value`.
339
- // QueryParams.$filter is a Record<string, any> — adapters convert to
340
- // the underlying query language (OData, ObjectQL, etc).
341
- if (dependsOn.length > 0) {
342
- const filterEntries: Record<string, any> = {};
343
- for (const { field, param } of dependsOn) {
344
- const v = resolvedDependentValues[field];
345
- if (v === undefined || v === null || v === '') continue;
346
- filterEntries[param] = typeof v === 'number' ? v : String(v);
347
- }
348
- if (Object.keys(filterEntries).length > 0) {
349
- params.$filter = filterEntries;
350
- }
351
- }
352
-
353
- // Apply base scoping filters so the quick-select popover matches the
354
- // full picker dialog (lookupFilters were previously dialog-only).
355
- if (lookupFilters && lookupFilters.length > 0) {
356
- params.$filter = { ...(params.$filter ?? {}), ...lookupFiltersToRecord(lookupFilters) };
357
- }
358
-
359
- const result = await dataSource.find(referenceTo, params);
360
- const records: any[] = result?.data ?? result ?? [];
361
- const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
362
-
363
- setFetchedOptions(mapped);
364
- setTotalCount(result?.total ?? records.length);
365
- } catch (err) {
366
- const msg = err instanceof Error ? err.message : String(err);
367
- setError(msg);
368
- setFetchedOptions([]);
369
- } finally {
370
- setLoading(false);
371
- }
372
- },
373
- [dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, dependenciesMissing, dependsOn, resolvedDependentValues, lookupFilters],
374
- );
375
-
376
- // Re-fetch when dependent values change while the picker is open. This keeps
377
- // a "City" dropdown reactive when the user changes "Country" without closing.
378
- const dependencySignature = useMemo(
379
- () => dependsOn.map(d => `${d.param}=${resolvedDependentValues[d.field] ?? ''}`).join('|'),
380
- [dependsOn, resolvedDependentValues],
381
- );
382
- useEffect(() => {
383
- if (isOpen && hasDataSource && dependsOn.length > 0) {
384
- fetchLookupData(searchQuery || undefined);
385
- // Clear local selection if it no longer satisfies the new filter chain —
386
- // out of scope here; consumer should validate on submit.
387
- }
388
- // eslint-disable-next-line react-hooks/exhaustive-deps
389
- }, [dependencySignature]);
390
-
391
- // Fetch data when dialog opens.
392
- // We intentionally depend only on `isOpen` so the effect fires once per
393
- // open/close transition. `fetchLookupData` is stable-enough via its own
394
- // useCallback deps; including it here would cause spurious re-fetches.
437
+ // Reset the keyboard cursor when the popover closes. Fetch state (records,
438
+ // search, error, total) is owned by `popoverQuery` and resets automatically
439
+ // when it becomes disabled (via `enabled`), including its debounced search.
395
440
  useEffect(() => {
396
- if (isOpen && hasDataSource) {
397
- fetchLookupData(searchQuery || undefined);
398
- }
399
- // Clean up fetched data when dialog closes
400
- if (!isOpen) {
401
- setSearchQuery('');
402
- setError(null);
403
- setActiveIndex(-1);
404
- }
405
- // eslint-disable-next-line react-hooks/exhaustive-deps
441
+ if (!isOpen) setActiveIndex(-1);
406
442
  }, [isOpen]);
407
443
 
408
- // Debounced search
444
+ // Search is the kernel's debounced setter.
409
445
  const handleSearchChange = useCallback(
410
- (query: string) => {
411
- setSearchQuery(query);
412
-
413
- if (!hasDataSource) return;
414
-
415
- if (debounceTimer.current) {
416
- clearTimeout(debounceTimer.current);
417
- }
418
- debounceTimer.current = setTimeout(() => {
419
- fetchLookupData(query || undefined);
420
- }, 300);
421
- },
422
- [hasDataSource, fetchLookupData],
446
+ (query: string) => popoverQuery.setSearch(query),
447
+ [popoverQuery.setSearch],
423
448
  );
424
449
 
425
- // Clean up debounce timer
426
- useEffect(() => {
427
- return () => {
428
- if (debounceTimer.current) {
429
- clearTimeout(debounceTimer.current);
430
- }
431
- };
432
- }, []);
433
-
434
450
  /**
435
451
  * Hydrate the picker's display when the field already has a value (e.g.
436
452
  * edit-mode load, prefill via query-string from a related-list "+ New")
@@ -440,9 +456,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
440
456
  */
441
457
  useEffect(() => {
442
458
  if (!hasDataSource || !dataSource || !referenceTo) return;
443
- const ids: any[] = multiple
459
+ const raw: any[] = multiple
444
460
  ? Array.isArray(value) ? value : []
445
461
  : value != null && value !== '' ? [value] : [];
462
+ // Expanded-reference values (server `$expand`, or their JSON-encoded string
463
+ // form) already carry their display fields and resolve directly in
464
+ // `resolveSelectedOption` — only bare ids need a fetch. Passing an object (or
465
+ // a JSON string) to `findOne` would query for a bogus id and leave the
466
+ // trigger stuck on the placeholder.
467
+ const ids = raw.filter(
468
+ (v) => v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v),
469
+ );
446
470
  if (!ids.length) return;
447
471
  // Only fetch records we haven't resolved yet.
448
472
  const unresolved = ids.filter((v) => !findOption(v));
@@ -455,14 +479,14 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
455
479
  for (const id of unresolved) {
456
480
  if (typeof (dataSource as any).findOne === 'function') {
457
481
  const rec = await (dataSource as any).findOne(referenceTo, id);
458
- if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat));
482
+ if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
459
483
  } else {
460
484
  const res = await dataSource.find(referenceTo, {
461
485
  $filter: { [idField]: id },
462
486
  $top: 1,
463
487
  } as QueryParams);
464
488
  const rows = res?.data ?? res ?? [];
465
- if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat));
489
+ if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
466
490
  }
467
491
  }
468
492
  if (!cancelled && fetched.length) {
@@ -494,9 +518,57 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
494
518
  [staticOptions, fetchedOptions, pickerResolvedRecords],
495
519
  );
496
520
 
521
+ // String-coerced fallback for `findOption` — matches the read cell's tolerant
522
+ // `String(a) === String(b)` comparison so a numeric cell value still resolves
523
+ // against a string-keyed option (and vice versa). Only consulted when the
524
+ // strict match misses, so homogeneous option lists are unaffected.
525
+ const findOptionLoose = useCallback(
526
+ (v: any): LookupOption | undefined => {
527
+ const key = String(v);
528
+ return (
529
+ staticOptions.find(opt => String(opt.value) === key) ??
530
+ fetchedOptions.find(opt => String(opt.value) === key) ??
531
+ pickerResolvedRecords.find(opt => String(opt.value) === key)
532
+ );
533
+ },
534
+ [staticOptions, fetchedOptions, pickerResolvedRecords],
535
+ );
536
+
537
+ // Collapse an expanded-reference value (the related record object returned by
538
+ // server `$expand`) to its bare id — used for option matching / highlighting.
539
+ const normalizeId = useCallback(
540
+ (raw: any): any => {
541
+ const obj = raw != null && typeof raw === 'object' ? raw : parseReferenceObjectString(raw);
542
+ return obj ? (obj[idField] ?? obj.id ?? obj._id ?? obj.externalId) : raw;
543
+ },
544
+ [idField],
545
+ );
546
+
547
+ // Resolve a raw field value into its display option. An expanded-reference
548
+ // object is mapped directly (mirroring the read cell's display-name path) so
549
+ // the inline editor shows the record's name instead of the placeholder; a bare
550
+ // id resolves through the static / fetched / picker-hydrated option lists.
551
+ const resolveSelectedOption = useCallback(
552
+ (raw: any): LookupOption | undefined => {
553
+ if (raw == null || raw === '') return undefined;
554
+ // An expanded-reference object (server `$expand`) — or its JSON-encoded
555
+ // string form, e.g. an external-id reference `'{"externalId":"…"}'` — is
556
+ // mapped directly, mirroring the read cell (`LookupCellRenderer`).
557
+ const asObject = typeof raw === 'object' ? raw : parseReferenceObjectString(raw);
558
+ if (asObject) {
559
+ return recordToOption(asObject, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
560
+ }
561
+ // Bare id: strict match first, then a String()-coerced fallback so a
562
+ // numeric cell value still resolves against a string-keyed option (and
563
+ // vice versa) — matching the read cell's tolerant comparison.
564
+ return findOption(raw) ?? findOptionLoose(raw);
565
+ },
566
+ [findOption, findOptionLoose, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
567
+ );
568
+
497
569
  const selectedOptions = multiple
498
- ? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean)
499
- : value ? [findOption(value)].filter(Boolean) : [];
570
+ ? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
571
+ : value ? [resolveSelectedOption(value)].filter(Boolean) : [];
500
572
 
501
573
  // Optional: receive the FULL selected record (not just its id) so a host can
502
574
  // auto-fill sibling fields from it — e.g. a line-item grid copying a product's
@@ -506,8 +578,22 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
506
578
 
507
579
  const handleSelect = useCallback(
508
580
  (option: LookupOption) => {
581
+ // Cache the picked option so its label resolves synchronously and durably,
582
+ // independent of the popover's `fetchedOptions` (which the editor may have
583
+ // remounted away, or which a slow/contended re-render hasn't surfaced yet —
584
+ // the intermittent CI failure where a just-picked lookup showed no label,
585
+ // #2150). `selectedOptions` consults `pickerResolvedRecords` in `findOption`.
586
+ if (option && option.value != null) {
587
+ setPickerResolvedRecords((prev) => {
588
+ const map = new Map(prev.map((o) => [o.value, o]));
589
+ map.set(option.value, option);
590
+ return Array.from(map.values());
591
+ });
592
+ }
509
593
  if (multiple) {
510
- const currentValues = Array.isArray(value) ? value : [];
594
+ // Normalise any expanded-reference objects to bare ids so toggling
595
+ // compares like-for-like and always persists ids (never mixed shapes).
596
+ const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
511
597
  const isSelected = currentValues.includes(option.value);
512
598
 
513
599
  if (isSelected) {
@@ -523,12 +609,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
523
609
  setIsOpen(false);
524
610
  }
525
611
  },
526
- [multiple, value, onChange, onSelectRecord, referenceTo],
612
+ [multiple, value, onChange, onSelectRecord, referenceTo, normalizeId],
527
613
  );
528
614
 
529
615
  const handleRemove = (optionValue: any) => {
530
616
  if (multiple) {
531
- const currentValues = Array.isArray(value) ? value : [];
617
+ const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
532
618
  onChange(currentValues.filter((v: any) => v !== optionValue));
533
619
  } else {
534
620
  onChange(null);
@@ -539,7 +625,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
539
625
  // findOption can resolve display labels after the dialog closes.
540
626
  const handlePickerSelectRecords = useCallback(
541
627
  (records: any[]) => {
542
- const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
628
+ const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
543
629
  if (referenceTo) mapped.forEach((o) => pushRecentLookupId(referenceTo, o.value));
544
630
  setPickerResolvedRecords(mapped);
545
631
  },
@@ -561,7 +647,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
561
647
  if (cached) { recs.push(cached); continue; }
562
648
  if (typeof (dataSource as any).findOne === 'function') {
563
649
  const r = await (dataSource as any).findOne(referenceTo, id);
564
- if (r) recs.push(recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat));
650
+ if (r) recs.push(recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
565
651
  }
566
652
  }
567
653
  if (!cancelled) setRecentOptions(recs);
@@ -582,26 +668,72 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
582
668
  useEffect(() => { setActiveIndex(-1); }, [visibleOptions.length]);
583
669
 
584
670
  const [creating, setCreating] = useState(false);
585
- const canCreate = !!onCreateNew || (allowCreate && hasDataSource);
671
+ // Open the referenced object's create form through the ActionProvider's modal
672
+ // handler (when a form host wired one). Safe outside a provider — useAction
673
+ // returns a local runner and useHasActionProvider is false.
674
+ const { execute } = useAction();
675
+ const hasActionProvider = useHasActionProvider();
676
+ const canCreate = !!onCreateNew || (allowCreate && (hasActionProvider || hasDataSource));
586
677
  const handleCreateNew = useCallback(
587
678
  async (q: string) => {
588
679
  const label = (q || '').trim();
589
680
  if (onCreateNew) { onCreateNew(label); setIsOpen(false); return; }
590
- if (!allowCreate || !dataSource || !referenceTo || !label) return;
681
+ if (!allowCreate || !referenceTo) return;
682
+ setCreateError(null);
683
+
684
+ // Preferred path — open the referenced object's FULL create form so the
685
+ // user fills every required field themselves, then select the record they
686
+ // created. This is the standard "create related record" behaviour: it
687
+ // works for ANY object (not only ones whose sole required field is the
688
+ // title) and turns an empty required picker from a dead end into a way to
689
+ // author the first related record.
690
+ if (hasActionProvider) {
691
+ setIsOpen(false); // close the picker popover first
692
+ // Defer opening the create dialog until the popover has fully closed and
693
+ // returned focus to the field trigger. Two nested-modal bugs come from
694
+ // opening it in the same tick as the triggering click:
695
+ // 1) the just-mounted Dialog treats this click's release (and the
696
+ // popover's own dismiss) as an outside-interaction and flashes shut
697
+ // on the FIRST click (works on the second);
698
+ // 2) with the popover already gone, the "+ create" button that Radix
699
+ // would return focus to is unmounted, so when the nested modal later
700
+ // closes focus leaks to <body> and dismisses the PARENT form's dialog
701
+ // too ("Cancel closes both").
702
+ // A macrotask runs after Radix's focus-return layout effects, so focus is
703
+ // safely back on the still-mounted field trigger before the Dialog opens.
704
+ await new Promise((r) => setTimeout(r, 0));
705
+ const result: any = await execute({
706
+ type: 'modal',
707
+ modal: { objectName: referenceTo, mode: 'create' },
708
+ } as any);
709
+ if (result?.success && result.data) {
710
+ const opt = recordToOption(result.data, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
711
+ setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
712
+ handleSelect(opt);
713
+ return;
714
+ }
715
+ // `success` + an echoed `modal` schema means no modal handler was wired
716
+ // in this tree → fall through to the legacy inline create. Anything else
717
+ // (cancel / no data) means the user backed out → stop.
718
+ if (!(result?.success && result.modal)) return;
719
+ }
720
+
721
+ // Fallback (no modal handler in scope): the legacy one-field inline create
722
+ // from the typed text. Best-effort; surfaces any validation error inline.
723
+ if (!dataSource || !label) return;
591
724
  setCreating(true);
592
- setError(null);
593
725
  try {
594
726
  const created = await (dataSource as any).create(referenceTo, { [displayField]: label });
595
- const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat);
727
+ const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
596
728
  setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
597
729
  handleSelect(opt);
598
730
  } catch (err) {
599
- setError(err instanceof Error ? err.message : String(err));
731
+ setCreateError(err instanceof Error ? err.message : String(err));
600
732
  } finally {
601
733
  setCreating(false);
602
734
  }
603
735
  },
604
- [onCreateNew, allowCreate, dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, handleSelect],
736
+ [onCreateNew, allowCreate, referenceTo, hasActionProvider, execute, dataSource, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema, handleSelect],
605
737
  );
606
738
 
607
739
  // Compact one-line preview of an option's extra (non-display) columns —
@@ -683,63 +815,121 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
683
815
  const compact = !!(props as any).compact;
684
816
  const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
685
817
 
818
+ // Shared field trigger — the anchor for either the inline PeoplePicker
819
+ // (search fields) or the classic quick-select popover. No onClick: the Radix
820
+ // trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
821
+ const triggerButton = (
822
+ <Button
823
+ variant="outline"
824
+ className={cn(
825
+ 'min-w-0 flex-1 justify-start text-left font-normal',
826
+ compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
827
+ )}
828
+ type="button"
829
+ disabled={dependenciesMissing || (props as any).disabled}
830
+ data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
831
+ title={dependenciesMissing
832
+ ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
833
+ : undefined}
834
+ >
835
+ <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
836
+ <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
837
+ {dependenciesMissing
838
+ ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
839
+ : compact && !multiple && selectedOptions.length > 0
840
+ ? singleSelectedLabel
841
+ : selectedOptions.length === 0
842
+ ? lookupField?.placeholder || t('common.select')
843
+ : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
844
+ </span>
845
+ </Button>
846
+ );
847
+
686
848
  return (
687
849
  <div className={compact ? '' : 'space-y-2'}>
688
850
  {/* Selected values display (full mode only — compact shows it in-trigger) */}
689
851
  {selectedOptions.length > 0 && !compact && (
690
852
  <div className="flex flex-wrap gap-1">
691
- {selectedOptions.map((opt, idx) => (
692
- <Badge
693
- key={idx}
694
- variant="outline"
695
- className="gap-1"
696
- >
697
- {opt?.label || opt?.[displayField]}
698
- <button
699
- onClick={() => handleRemove(opt?.value)}
700
- className="ml-1 hover:text-destructive"
701
- type="button"
702
- aria-label={t('lookup.remove', { label: opt?.label || opt?.[displayField] })}
703
- >
704
- <X className="size-3" />
705
- </button>
706
- </Badge>
707
- ))}
853
+ {selectedOptions.map((opt, idx) => {
854
+ const chipLabel = opt?.label || opt?.[displayField];
855
+ // Search-first (people) fields show avatar chips; classic lookups
856
+ // keep the plain text Badge.
857
+ if (pickerVariant === 'search') {
858
+ const avatarUrl = (opt as any)?.[avatarField] || (opt as any)?.image;
859
+ return (
860
+ <span
861
+ key={idx}
862
+ data-testid="people-field-chip"
863
+ className="inline-flex items-center gap-1.5 rounded-full border bg-background py-0.5 pl-0.5 pr-1.5 text-sm"
864
+ >
865
+ <Avatar className="size-6 shrink-0">
866
+ {avatarUrl && <AvatarImage src={avatarUrl} alt={String(chipLabel || '')} />}
867
+ <AvatarFallback className="text-[10px]">
868
+ {getPersonInitials(String(chipLabel || ''))}
869
+ </AvatarFallback>
870
+ </Avatar>
871
+ <span className="max-w-[10rem] truncate">{chipLabel}</span>
872
+ <button
873
+ onClick={() => handleRemove(opt?.value)}
874
+ className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
875
+ type="button"
876
+ aria-label={t('lookup.remove', { label: chipLabel })}
877
+ >
878
+ <X className="size-3" />
879
+ </button>
880
+ </span>
881
+ );
882
+ }
883
+ return (
884
+ <Badge key={idx} variant="outline" className="gap-1">
885
+ {chipLabel}
886
+ <button
887
+ onClick={() => handleRemove(opt?.value)}
888
+ className="ml-1 hover:text-destructive"
889
+ type="button"
890
+ aria-label={t('lookup.remove', { label: chipLabel })}
891
+ >
892
+ <X className="size-3" />
893
+ </button>
894
+ </Badge>
895
+ );
896
+ })}
708
897
  </div>
709
898
  )}
710
899
 
711
- {/* Level 1: Quick-select Popover (inline typeahead) */}
900
+ {/* Field control: search-first inline combobox (anchored dropdown / mobile
901
+ sheet), else the classic quick-select popover. */}
902
+ {pickerVariant === 'search' && hasDataSource && dataSource && referenceTo ? (
903
+ <PeoplePicker
904
+ inline
905
+ trigger={triggerButton}
906
+ open={isPickerOpen}
907
+ onOpenChange={setIsPickerOpen}
908
+ title={lookupField?.label || t('common.select')}
909
+ multiple={multiple}
910
+ dataSource={dataSource}
911
+ objectName={referenceTo}
912
+ displayField={displayField}
913
+ idField={idField}
914
+ subtitleFields={subtitleFields}
915
+ avatarField={avatarField}
916
+ pageSize={lookupPageSize}
917
+ value={value}
918
+ onSelect={onChange}
919
+ onSelectRecords={handlePickerSelectRecords}
920
+ lookupFilters={lookupFilters}
921
+ baseFilter={dependentFilter}
922
+ />
923
+ ) : (
712
924
  <div className="flex items-center gap-1.5">
713
- <Popover open={isOpen} onOpenChange={(o) => !dependenciesMissing && setIsOpen(o)}>
925
+ <Popover
926
+ open={isOpen}
927
+ onOpenChange={(o) => {
928
+ if (!dependenciesMissing) setIsOpen(o);
929
+ }}
930
+ >
714
931
  <PopoverTrigger asChild>
715
- <Button
716
- variant="outline"
717
- className={cn(
718
- 'min-w-0 flex-1 justify-start text-left font-normal',
719
- compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
720
- )}
721
- type="button"
722
- aria-haspopup="listbox"
723
- aria-expanded={isOpen}
724
- aria-controls={listboxId}
725
- disabled={dependenciesMissing || (props as any).disabled}
726
- data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
727
- title={dependenciesMissing
728
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
729
- : undefined}
730
- >
731
- <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
732
- <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
733
- {dependenciesMissing
734
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
735
- : compact && !multiple && selectedOptions.length > 0
736
- ? singleSelectedLabel
737
- : selectedOptions.length === 0
738
- ? lookupField?.placeholder || t('common.select')
739
- : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
740
- }
741
- </span>
742
- </Button>
932
+ {triggerButton}
743
933
  </PopoverTrigger>
744
934
  <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
745
935
  {/* Search input */}
@@ -775,7 +965,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
775
965
  <Button
776
966
  variant="outline"
777
967
  size="sm"
778
- onClick={() => fetchLookupData(searchQuery || undefined)}
968
+ onClick={() => popoverQuery.refetch()}
779
969
  type="button"
780
970
  >
781
971
  {t('lookup.retry')}
@@ -818,8 +1008,8 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
818
1008
  <>
819
1009
  {visibleOptions.map((option, idx) => {
820
1010
  const isSelected = multiple
821
- ? (Array.isArray(value) ? value : []).includes(option.value)
822
- : value === option.value;
1011
+ ? (Array.isArray(value) ? value : []).map(normalizeId).includes(option.value)
1012
+ : normalizeId(value) === option.value;
823
1013
  const isActive = idx === activeIndex;
824
1014
  const showRecentHeader = recentCount > 0 && idx === 0;
825
1015
  const showResultsHeader = recentCount > 0 && idx === recentCount;
@@ -907,25 +1097,32 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
907
1097
  </PopoverContent>
908
1098
  </Popover>
909
1099
 
910
- {/* "Browse All" button — always visible when DataSource is available */}
911
- {hasDataSource && (
1100
+ {/* "Browse All" button — classic lookups only; search fields open the
1101
+ PeoplePicker from the trigger itself, so this would be redundant. */}
1102
+ {hasDataSource && pickerVariant !== 'search' && (
912
1103
  <Button
913
1104
  variant="outline"
914
1105
  size="icon"
915
1106
  className="shrink-0"
916
1107
  type="button"
1108
+ // Gated exactly like the main trigger (#2215) — pre-fix this button
1109
+ // opened the full unscoped table while the dependency was missing.
1110
+ disabled={dependenciesMissing || (props as any).disabled}
917
1111
  onClick={() => setIsPickerOpen(true)}
918
1112
  aria-label={t('lookup.browseAll')}
919
- title={t('lookup.browseAll')}
1113
+ title={dependenciesMissing
1114
+ ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
1115
+ : t('lookup.browseAll')}
920
1116
  data-testid="browse-all-records"
921
1117
  >
922
1118
  <TableProperties className="size-4" />
923
1119
  </Button>
924
1120
  )}
925
1121
  </div>
1122
+ )}
926
1123
 
927
- {/* Level 2: Full Record Picker Dialog */}
928
- {hasDataSource && dataSource && referenceTo && (
1124
+ {/* Level 2: classic table picker — search fields use the inline combobox above. */}
1125
+ {hasDataSource && dataSource && referenceTo && pickerVariant !== 'search' && (
929
1126
  <RecordPickerDialog
930
1127
  open={isPickerOpen}
931
1128
  onOpenChange={setIsPickerOpen}
@@ -942,6 +1139,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
942
1139
  onSelect={onChange}
943
1140
  onSelectRecords={handlePickerSelectRecords}
944
1141
  lookupFilters={lookupFilters}
1142
+ baseFilter={dependentFilter}
945
1143
  cellRenderer={getCellRendererResolver()}
946
1144
  filterColumns={filterColumns}
947
1145
  />