@object-ui/fields 11.3.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 +139 -0
  2. package/dist/FieldEditWidget.d.ts +8 -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 +7 -0
  8. package/dist/index.js +1987 -1326
  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 +7 -7
  24. package/src/FieldEditWidget.test.ts +61 -0
  25. package/src/FieldEditWidget.tsx +56 -1
  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 +32 -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 +365 -190
  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,11 +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';
15
20
  import { getRecordDisplayName } from '@object-ui/core';
16
21
  import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
22
+ import { getPersonInitials } from './personDisplay';
17
23
  import { getCellRendererResolver } from './_cell-renderer-bridge';
18
- import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
24
+ import { SchemaRendererContext as ImportedSchemaRendererContext, useAction, useHasActionProvider } from '@object-ui/react';
19
25
  import { useFieldTranslation } from './useFieldTranslation';
20
26
 
21
27
  export interface LookupOption {
@@ -34,6 +40,22 @@ const LOOKUP_PAGE_SIZE = 50;
34
40
  */
35
41
  const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
36
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
+
37
59
  /**
38
60
  * Render a record title from a `titleFormat` template (e.g. `{full_name}` or
39
61
  * `{case_number} - {subject}`). When a templated key resolves to an empty
@@ -90,7 +112,7 @@ function recordToOption(
90
112
  titleFormat?: string | null,
91
113
  objectDef?: any,
92
114
  ): LookupOption {
93
- const val = record[idField] ?? record.id ?? record._id;
115
+ const val = record[idField] ?? record.id ?? record._id ?? record.externalId;
94
116
  const templated = titleFormat ? formatRecordTitle(record, titleFormat) : null;
95
117
 
96
118
  // Object-level resolver fallback (displayNameField + derivation), excluding
@@ -114,11 +136,31 @@ function recordToOption(
114
136
  record.full_name ??
115
137
  record.title ??
116
138
  record.subject ??
139
+ record.externalId ??
117
140
  String(val);
118
141
  const description = descriptionField ? record[descriptionField] : undefined;
119
142
  return { value: val, label: String(label), description, ...record };
120
143
  }
121
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
+
122
164
  /**
123
165
  * Map a LookupColumnDef.type to a filter input type for the filter bar.
124
166
  * Returns undefined if the field type is not filterable.
@@ -151,16 +193,12 @@ function mapFieldTypeToFilterType(
151
193
  */
152
194
  export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
153
195
  const [isOpen, setIsOpen] = useState(false);
154
- const [searchQuery, setSearchQuery] = useState('');
155
196
  const { t } = useFieldTranslation();
156
197
  const listboxId = React.useId();
157
198
 
158
- // Dynamic data loading state
159
- const [fetchedOptions, setFetchedOptions] = useState<LookupOption[]>([]);
160
- const [loading, setLoading] = useState(false);
161
- const [error, setError] = useState<string | null>(null);
162
- const [totalCount, setTotalCount] = useState(0);
163
- 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);
164
202
 
165
203
  // Records selected via RecordPickerDialog (Level 2).
166
204
  // Stored as LookupOption so that findOption can resolve display labels
@@ -190,15 +228,28 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
190
228
  const idField = fieldMeta?.id_field || 'id';
191
229
  // ObjectStack convention uses `reference`; types define `reference_to` — support both
192
230
  const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
193
- // Opt-in inline quick-create: when set, an empty/zero-result picker offers to
194
- // create a record from the typed text via dataSource.create.
195
- 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);
196
240
 
197
241
  // Enterprise Record Picker configuration
198
242
  const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns;
199
243
  const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize;
200
244
  const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters;
201
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
+
202
253
  /**
203
254
  * Dependent lookups — restrict candidates based on values of *other* fields
204
255
  * in the same form. Two shapes are accepted:
@@ -317,7 +368,54 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
317
368
  // State for the full Record Picker dialog (Level 2)
318
369
  const [isPickerOpen, setIsPickerOpen] = useState(false);
319
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
+
320
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
+
321
419
  const allOptions = hasDataSource ? fetchedOptions : staticOptions;
322
420
 
323
421
  // For static options, filter locally based on search
@@ -336,124 +434,19 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
336
434
  setActiveIndex(-1);
337
435
  }, [filteredOptions.length]);
338
436
 
339
- // Fetch data from DataSource
340
- const fetchLookupData = useCallback(
341
- async (search?: string) => {
342
- if (!dataSource || !referenceTo) return;
343
- // Don't issue a request that ignores configured dependencies.
344
- if (dependenciesMissing) {
345
- setFetchedOptions([]);
346
- setTotalCount(0);
347
- return;
348
- }
349
-
350
- setLoading(true);
351
- setError(null);
352
-
353
- try {
354
- const params: QueryParams = {
355
- $top: LOOKUP_PAGE_SIZE,
356
- };
357
- if (search && search.trim()) {
358
- params.$search = search.trim();
359
- }
360
-
361
- // Build a dependent-lookup filter chain: AND of `param eq value`.
362
- // QueryParams.$filter is a Record<string, any> — adapters convert to
363
- // the underlying query language (OData, ObjectQL, etc).
364
- if (dependsOn.length > 0) {
365
- const filterEntries: Record<string, any> = {};
366
- for (const { field, param } of dependsOn) {
367
- const v = resolvedDependentValues[field];
368
- if (v === undefined || v === null || v === '') continue;
369
- filterEntries[param] = typeof v === 'number' ? v : String(v);
370
- }
371
- if (Object.keys(filterEntries).length > 0) {
372
- params.$filter = filterEntries;
373
- }
374
- }
375
-
376
- // Apply base scoping filters so the quick-select popover matches the
377
- // full picker dialog (lookupFilters were previously dialog-only).
378
- if (lookupFilters && lookupFilters.length > 0) {
379
- params.$filter = { ...(params.$filter ?? {}), ...lookupFiltersToRecord(lookupFilters) };
380
- }
381
-
382
- const result = await dataSource.find(referenceTo, params);
383
- const records: any[] = result?.data ?? result ?? [];
384
- const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
385
-
386
- setFetchedOptions(mapped);
387
- setTotalCount(result?.total ?? records.length);
388
- } catch (err) {
389
- const msg = err instanceof Error ? err.message : String(err);
390
- setError(msg);
391
- setFetchedOptions([]);
392
- } finally {
393
- setLoading(false);
394
- }
395
- },
396
- [dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, dependenciesMissing, dependsOn, resolvedDependentValues, lookupFilters],
397
- );
398
-
399
- // Re-fetch when dependent values change while the picker is open. This keeps
400
- // a "City" dropdown reactive when the user changes "Country" without closing.
401
- const dependencySignature = useMemo(
402
- () => dependsOn.map(d => `${d.param}=${resolvedDependentValues[d.field] ?? ''}`).join('|'),
403
- [dependsOn, resolvedDependentValues],
404
- );
405
- useEffect(() => {
406
- if (isOpen && hasDataSource && dependsOn.length > 0) {
407
- fetchLookupData(searchQuery || undefined);
408
- // Clear local selection if it no longer satisfies the new filter chain —
409
- // out of scope here; consumer should validate on submit.
410
- }
411
- // eslint-disable-next-line react-hooks/exhaustive-deps
412
- }, [dependencySignature]);
413
-
414
- // Fetch data when dialog opens.
415
- // We intentionally depend only on `isOpen` so the effect fires once per
416
- // open/close transition. `fetchLookupData` is stable-enough via its own
417
- // 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.
418
440
  useEffect(() => {
419
- if (isOpen && hasDataSource) {
420
- fetchLookupData(searchQuery || undefined);
421
- }
422
- // Clean up fetched data when dialog closes
423
- if (!isOpen) {
424
- setSearchQuery('');
425
- setError(null);
426
- setActiveIndex(-1);
427
- }
428
- // eslint-disable-next-line react-hooks/exhaustive-deps
441
+ if (!isOpen) setActiveIndex(-1);
429
442
  }, [isOpen]);
430
443
 
431
- // Debounced search
444
+ // Search is the kernel's debounced setter.
432
445
  const handleSearchChange = useCallback(
433
- (query: string) => {
434
- setSearchQuery(query);
435
-
436
- if (!hasDataSource) return;
437
-
438
- if (debounceTimer.current) {
439
- clearTimeout(debounceTimer.current);
440
- }
441
- debounceTimer.current = setTimeout(() => {
442
- fetchLookupData(query || undefined);
443
- }, 300);
444
- },
445
- [hasDataSource, fetchLookupData],
446
+ (query: string) => popoverQuery.setSearch(query),
447
+ [popoverQuery.setSearch],
446
448
  );
447
449
 
448
- // Clean up debounce timer
449
- useEffect(() => {
450
- return () => {
451
- if (debounceTimer.current) {
452
- clearTimeout(debounceTimer.current);
453
- }
454
- };
455
- }, []);
456
-
457
450
  /**
458
451
  * Hydrate the picker's display when the field already has a value (e.g.
459
452
  * edit-mode load, prefill via query-string from a related-list "+ New")
@@ -463,9 +456,17 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
463
456
  */
464
457
  useEffect(() => {
465
458
  if (!hasDataSource || !dataSource || !referenceTo) return;
466
- const ids: any[] = multiple
459
+ const raw: any[] = multiple
467
460
  ? Array.isArray(value) ? value : []
468
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
+ );
469
470
  if (!ids.length) return;
470
471
  // Only fetch records we haven't resolved yet.
471
472
  const unresolved = ids.filter((v) => !findOption(v));
@@ -517,9 +518,57 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
517
518
  [staticOptions, fetchedOptions, pickerResolvedRecords],
518
519
  );
519
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
+
520
569
  const selectedOptions = multiple
521
- ? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean)
522
- : value ? [findOption(value)].filter(Boolean) : [];
570
+ ? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
571
+ : value ? [resolveSelectedOption(value)].filter(Boolean) : [];
523
572
 
524
573
  // Optional: receive the FULL selected record (not just its id) so a host can
525
574
  // auto-fill sibling fields from it — e.g. a line-item grid copying a product's
@@ -529,8 +578,22 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
529
578
 
530
579
  const handleSelect = useCallback(
531
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
+ }
532
593
  if (multiple) {
533
- 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);
534
597
  const isSelected = currentValues.includes(option.value);
535
598
 
536
599
  if (isSelected) {
@@ -546,12 +609,12 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
546
609
  setIsOpen(false);
547
610
  }
548
611
  },
549
- [multiple, value, onChange, onSelectRecord, referenceTo],
612
+ [multiple, value, onChange, onSelectRecord, referenceTo, normalizeId],
550
613
  );
551
614
 
552
615
  const handleRemove = (optionValue: any) => {
553
616
  if (multiple) {
554
- const currentValues = Array.isArray(value) ? value : [];
617
+ const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
555
618
  onChange(currentValues.filter((v: any) => v !== optionValue));
556
619
  } else {
557
620
  onChange(null);
@@ -605,26 +668,72 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
605
668
  useEffect(() => { setActiveIndex(-1); }, [visibleOptions.length]);
606
669
 
607
670
  const [creating, setCreating] = useState(false);
608
- 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));
609
677
  const handleCreateNew = useCallback(
610
678
  async (q: string) => {
611
679
  const label = (q || '').trim();
612
680
  if (onCreateNew) { onCreateNew(label); setIsOpen(false); return; }
613
- 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;
614
724
  setCreating(true);
615
- setError(null);
616
725
  try {
617
726
  const created = await (dataSource as any).create(referenceTo, { [displayField]: label });
618
727
  const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
619
728
  setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
620
729
  handleSelect(opt);
621
730
  } catch (err) {
622
- setError(err instanceof Error ? err.message : String(err));
731
+ setCreateError(err instanceof Error ? err.message : String(err));
623
732
  } finally {
624
733
  setCreating(false);
625
734
  }
626
735
  },
627
- [onCreateNew, allowCreate, dataSource, referenceTo, displayField, idField, effectiveDescriptionField, refTitleFormat, handleSelect],
736
+ [onCreateNew, allowCreate, referenceTo, hasActionProvider, execute, dataSource, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema, handleSelect],
628
737
  );
629
738
 
630
739
  // Compact one-line preview of an option's extra (non-display) columns —
@@ -706,63 +815,121 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
706
815
  const compact = !!(props as any).compact;
707
816
  const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
708
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
+
709
848
  return (
710
849
  <div className={compact ? '' : 'space-y-2'}>
711
850
  {/* Selected values display (full mode only — compact shows it in-trigger) */}
712
851
  {selectedOptions.length > 0 && !compact && (
713
852
  <div className="flex flex-wrap gap-1">
714
- {selectedOptions.map((opt, idx) => (
715
- <Badge
716
- key={idx}
717
- variant="outline"
718
- className="gap-1"
719
- >
720
- {opt?.label || opt?.[displayField]}
721
- <button
722
- onClick={() => handleRemove(opt?.value)}
723
- className="ml-1 hover:text-destructive"
724
- type="button"
725
- aria-label={t('lookup.remove', { label: opt?.label || opt?.[displayField] })}
726
- >
727
- <X className="size-3" />
728
- </button>
729
- </Badge>
730
- ))}
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
+ })}
731
897
  </div>
732
898
  )}
733
899
 
734
- {/* 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
+ ) : (
735
924
  <div className="flex items-center gap-1.5">
736
- <Popover open={isOpen} onOpenChange={(o) => !dependenciesMissing && setIsOpen(o)}>
925
+ <Popover
926
+ open={isOpen}
927
+ onOpenChange={(o) => {
928
+ if (!dependenciesMissing) setIsOpen(o);
929
+ }}
930
+ >
737
931
  <PopoverTrigger asChild>
738
- <Button
739
- variant="outline"
740
- className={cn(
741
- 'min-w-0 flex-1 justify-start text-left font-normal',
742
- compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
743
- )}
744
- type="button"
745
- aria-haspopup="listbox"
746
- aria-expanded={isOpen}
747
- aria-controls={listboxId}
748
- disabled={dependenciesMissing || (props as any).disabled}
749
- data-testid={dependenciesMissing ? 'lookup-trigger-gated' : (((props as any).name || lookupField?.name) ? `lookup-trigger-${(props as any).name || lookupField.name}` : 'lookup-trigger')}
750
- title={dependenciesMissing
751
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
752
- : undefined}
753
- >
754
- <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
755
- <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
756
- {dependenciesMissing
757
- ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
758
- : compact && !multiple && selectedOptions.length > 0
759
- ? singleSelectedLabel
760
- : selectedOptions.length === 0
761
- ? lookupField?.placeholder || t('common.select')
762
- : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')
763
- }
764
- </span>
765
- </Button>
932
+ {triggerButton}
766
933
  </PopoverTrigger>
767
934
  <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
768
935
  {/* Search input */}
@@ -798,7 +965,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
798
965
  <Button
799
966
  variant="outline"
800
967
  size="sm"
801
- onClick={() => fetchLookupData(searchQuery || undefined)}
968
+ onClick={() => popoverQuery.refetch()}
802
969
  type="button"
803
970
  >
804
971
  {t('lookup.retry')}
@@ -841,8 +1008,8 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
841
1008
  <>
842
1009
  {visibleOptions.map((option, idx) => {
843
1010
  const isSelected = multiple
844
- ? (Array.isArray(value) ? value : []).includes(option.value)
845
- : value === option.value;
1011
+ ? (Array.isArray(value) ? value : []).map(normalizeId).includes(option.value)
1012
+ : normalizeId(value) === option.value;
846
1013
  const isActive = idx === activeIndex;
847
1014
  const showRecentHeader = recentCount > 0 && idx === 0;
848
1015
  const showResultsHeader = recentCount > 0 && idx === recentCount;
@@ -930,25 +1097,32 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
930
1097
  </PopoverContent>
931
1098
  </Popover>
932
1099
 
933
- {/* "Browse All" button — always visible when DataSource is available */}
934
- {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' && (
935
1103
  <Button
936
1104
  variant="outline"
937
1105
  size="icon"
938
1106
  className="shrink-0"
939
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}
940
1111
  onClick={() => setIsPickerOpen(true)}
941
1112
  aria-label={t('lookup.browseAll')}
942
- title={t('lookup.browseAll')}
1113
+ title={dependenciesMissing
1114
+ ? t('lookup.selectFirst', { fields: dependsOn.map(d => d.field).join(', ') })
1115
+ : t('lookup.browseAll')}
943
1116
  data-testid="browse-all-records"
944
1117
  >
945
1118
  <TableProperties className="size-4" />
946
1119
  </Button>
947
1120
  )}
948
1121
  </div>
1122
+ )}
949
1123
 
950
- {/* Level 2: Full Record Picker Dialog */}
951
- {hasDataSource && dataSource && referenceTo && (
1124
+ {/* Level 2: classic table picker — search fields use the inline combobox above. */}
1125
+ {hasDataSource && dataSource && referenceTo && pickerVariant !== 'search' && (
952
1126
  <RecordPickerDialog
953
1127
  open={isPickerOpen}
954
1128
  onOpenChange={setIsPickerOpen}
@@ -965,6 +1139,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
965
1139
  onSelect={onChange}
966
1140
  onSelectRecords={handlePickerSelectRecords}
967
1141
  lookupFilters={lookupFilters}
1142
+ baseFilter={dependentFilter}
968
1143
  cellRenderer={getCellRendererResolver()}
969
1144
  filterColumns={filterColumns}
970
1145
  />