@object-ui/fields 17.3.0 → 17.6.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 (265) hide show
  1. package/CHANGELOG.md +1655 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +23 -3
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +35 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +651 -0
  8. package/dist/index.d.ts +474 -85
  9. package/dist/index.js +5418 -5023
  10. package/dist/widgets/AddressField.d.ts +4 -12
  11. package/dist/widgets/AutoNumberField.d.ts +1 -1
  12. package/dist/widgets/AvatarField.d.ts +1 -1
  13. package/dist/widgets/BooleanField.d.ts +1 -1
  14. package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
  15. package/dist/widgets/CheckboxesField.d.ts +1 -1
  16. package/dist/widgets/CodeField.d.ts +1 -1
  17. package/dist/widgets/ColorField.d.ts +1 -1
  18. package/dist/widgets/CurrencyField.d.ts +1 -1
  19. package/dist/widgets/DateField.d.ts +1 -1
  20. package/dist/widgets/DateTimeField.d.ts +1 -1
  21. package/dist/widgets/EmailField.d.ts +1 -1
  22. package/dist/widgets/FileField.d.ts +18 -2
  23. package/dist/widgets/FilterConditionField.d.ts +28 -1
  24. package/dist/widgets/FormulaField.d.ts +1 -1
  25. package/dist/widgets/FullscreenFieldEditor.d.ts +49 -152
  26. package/dist/widgets/GeolocationField.d.ts +1 -1
  27. package/dist/widgets/GridField.d.ts +68 -6
  28. package/dist/widgets/ImageField.d.ts +1 -1
  29. package/dist/widgets/LocationField.d.ts +1 -1
  30. package/dist/widgets/LookupField.d.ts +1 -1
  31. package/dist/widgets/MasterDetailField.d.ts +1 -1
  32. package/dist/widgets/MultiSelectField.d.ts +1 -1
  33. package/dist/widgets/NumberField.d.ts +1 -1
  34. package/dist/widgets/ObjectField.d.ts +1 -1
  35. package/dist/widgets/ObjectRefField.d.ts +1 -1
  36. package/dist/widgets/OptionsEmptyState.d.ts +21 -1
  37. package/dist/widgets/PasswordField.d.ts +1 -1
  38. package/dist/widgets/PercentField.d.ts +1 -1
  39. package/dist/widgets/PhoneField.d.ts +1 -1
  40. package/dist/widgets/QRCodeField.d.ts +1 -1
  41. package/dist/widgets/RadioField.d.ts +1 -1
  42. package/dist/widgets/RatingField.d.ts +1 -1
  43. package/dist/widgets/RecipientPickerField.d.ts +1 -1
  44. package/dist/widgets/RecordPickerDialog.d.ts +34 -10
  45. package/dist/widgets/RichTextField.d.ts +37 -1
  46. package/dist/widgets/SelectField.d.ts +26 -6
  47. package/dist/widgets/SignatureField.d.ts +32 -2
  48. package/dist/widgets/SliderField.d.ts +37 -2
  49. package/dist/widgets/SummaryField.d.ts +1 -1
  50. package/dist/widgets/TagsField.d.ts +1 -1
  51. package/dist/widgets/TextAreaField.d.ts +6 -2
  52. package/dist/widgets/TextField.d.ts +1 -1
  53. package/dist/widgets/TimeField.d.ts +1 -1
  54. package/dist/widgets/UrlField.d.ts +1 -1
  55. package/dist/widgets/UserField.d.ts +1 -1
  56. package/dist/widgets/VectorField.d.ts +1 -1
  57. package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
  58. package/dist/widgets/address-format.d.ts +88 -0
  59. package/dist/widgets/file-size-guard.d.ts +41 -0
  60. package/dist/widgets/lookupColumnDisplay.d.ts +93 -0
  61. package/dist/widgets/nativeDateValue.d.ts +37 -0
  62. package/dist/widgets/richTextDisplay.d.ts +80 -0
  63. package/dist/widgets/toDomProps.d.ts +30 -10
  64. package/dist/widgets/toHostGroupProps.d.ts +189 -0
  65. package/dist/widgets/types.d.ts +8 -3
  66. package/dist/widgets/useRecordQuery.d.ts +7 -1
  67. package/package.json +17 -14
  68. package/dist/FieldEditWidget.test.d.ts +0 -8
  69. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  70. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  71. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  72. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  73. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  74. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  75. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  76. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  77. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  78. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  79. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  80. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  81. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  82. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  83. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  84. package/dist/coerce-safe-value.test.d.ts +0 -1
  85. package/dist/complex-widgets.test.d.ts +0 -1
  86. package/dist/datetime-widgets.test.d.ts +0 -1
  87. package/dist/field-type-coverage.test.d.ts +0 -1
  88. package/dist/standard-widgets.test.d.ts +0 -0
  89. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  90. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  91. package/dist/widgets/CharacterCount.d.ts +0 -56
  92. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  93. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  94. package/dist/widgets/GridField.test.d.ts +0 -1
  95. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  96. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  97. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  98. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  99. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  100. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  101. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  102. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  103. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  104. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  105. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  106. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  107. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  108. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  109. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  110. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  111. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  112. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  113. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  114. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  115. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  116. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  117. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  118. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  119. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  120. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  121. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  122. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  123. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  124. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  125. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  126. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  127. package/dist/widgets/file-value.test.d.ts +0 -1
  128. package/dist/widgets/personDisplay.test.d.ts +0 -8
  129. package/dist/widgets/recentLookups.test.d.ts +0 -1
  130. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  131. package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
  132. package/src/FieldEditWidget.test.ts +0 -133
  133. package/src/FieldEditWidget.tsx +0 -191
  134. package/src/__tests__/DateCellRenderer.test.tsx +0 -73
  135. package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
  136. package/src/__tests__/cell-truncation.test.tsx +0 -96
  137. package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
  138. package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
  139. package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
  140. package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
  141. package/src/__tests__/registry-collision.test.ts +0 -91
  142. package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
  143. package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
  144. package/src/__tests__/validation-feedback.test.tsx +0 -361
  145. package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
  146. package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
  147. package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -598
  148. package/src/__tests__/widget-props-contract.test.tsx +0 -142
  149. package/src/coerce-safe-value.test.ts +0 -34
  150. package/src/complex-widgets.test.tsx +0 -908
  151. package/src/currency.ts +0 -14
  152. package/src/datetime-widgets.test.tsx +0 -93
  153. package/src/field-type-alias.ts +0 -100
  154. package/src/field-type-coverage.test.ts +0 -67
  155. package/src/index.tsx +0 -2503
  156. package/src/standard-widgets.test.tsx +0 -357
  157. package/src/widgets/AddressField.tsx +0 -132
  158. package/src/widgets/AddressField.uniqueIds.test.tsx +0 -92
  159. package/src/widgets/AutoNumberField.tsx +0 -18
  160. package/src/widgets/AvatarField.tsx +0 -117
  161. package/src/widgets/BooleanField.tsx +0 -52
  162. package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
  163. package/src/widgets/CapabilityMultiSelectField.tsx +0 -229
  164. package/src/widgets/CharacterCount.tsx +0 -263
  165. package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
  166. package/src/widgets/CheckboxesField.tsx +0 -136
  167. package/src/widgets/CodeField.tsx +0 -41
  168. package/src/widgets/ColorField.tsx +0 -53
  169. package/src/widgets/CurrencyField.tsx +0 -95
  170. package/src/widgets/DateField.tsx +0 -31
  171. package/src/widgets/DateTimeField.tsx +0 -37
  172. package/src/widgets/EmailField.tsx +0 -37
  173. package/src/widgets/FileField.tsx +0 -494
  174. package/src/widgets/FilterConditionField.tsx +0 -454
  175. package/src/widgets/FormulaField.tsx +0 -33
  176. package/src/widgets/FullscreenFieldEditor.tsx +0 -271
  177. package/src/widgets/GeolocationField.tsx +0 -167
  178. package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
  179. package/src/widgets/GridField.test.tsx +0 -406
  180. package/src/widgets/GridField.tsx +0 -966
  181. package/src/widgets/ImageCropperDialog.tsx +0 -245
  182. package/src/widgets/ImageField.tsx +0 -255
  183. package/src/widgets/ImageLightbox.test.tsx +0 -52
  184. package/src/widgets/ImageLightbox.tsx +0 -108
  185. package/src/widgets/LocationField.tsx +0 -57
  186. package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
  187. package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
  188. package/src/widgets/LookupField.hydration.test.tsx +0 -136
  189. package/src/widgets/LookupField.idField.test.tsx +0 -61
  190. package/src/widgets/LookupField.tsx +0 -1291
  191. package/src/widgets/MarkdownContent.tsx +0 -19
  192. package/src/widgets/MasterDetailField.tsx +0 -131
  193. package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
  194. package/src/widgets/MultiSelectField.tsx +0 -142
  195. package/src/widgets/NumberField.tsx +0 -49
  196. package/src/widgets/ObjectField.tsx +0 -94
  197. package/src/widgets/ObjectRefField.tsx +0 -121
  198. package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
  199. package/src/widgets/OptionsEmptyState.test.tsx +0 -169
  200. package/src/widgets/OptionsEmptyState.tsx +0 -83
  201. package/src/widgets/PasswordField.tsx +0 -49
  202. package/src/widgets/PeoplePicker.test.tsx +0 -360
  203. package/src/widgets/PeoplePicker.tsx +0 -579
  204. package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
  205. package/src/widgets/PercentField.tsx +0 -95
  206. package/src/widgets/PersonRow.tsx +0 -117
  207. package/src/widgets/PhoneField.tsx +0 -37
  208. package/src/widgets/QRCodeField.tsx +0 -103
  209. package/src/widgets/RadioField.cascade.test.tsx +0 -141
  210. package/src/widgets/RadioField.tsx +0 -111
  211. package/src/widgets/RatingField.tsx +0 -81
  212. package/src/widgets/RecipientPickerField.test.tsx +0 -157
  213. package/src/widgets/RecipientPickerField.tsx +0 -188
  214. package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
  215. package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
  216. package/src/widgets/RecordPickerDialog.tsx +0 -1316
  217. package/src/widgets/RichTextField.tsx +0 -176
  218. package/src/widgets/SelectField.cascade.test.tsx +0 -138
  219. package/src/widgets/SelectField.tsx +0 -162
  220. package/src/widgets/SelectionTray.tsx +0 -110
  221. package/src/widgets/SignatureField.tsx +0 -140
  222. package/src/widgets/SliderField.tsx +0 -39
  223. package/src/widgets/SummaryField.tsx +0 -31
  224. package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
  225. package/src/widgets/TagsField.placeholder.test.tsx +0 -95
  226. package/src/widgets/TagsField.tsx +0 -88
  227. package/src/widgets/TextAreaField.tsx +0 -213
  228. package/src/widgets/TextField.tsx +0 -45
  229. package/src/widgets/TimeField.tsx +0 -31
  230. package/src/widgets/UrlField.tsx +0 -46
  231. package/src/widgets/UserField.tsx +0 -58
  232. package/src/widgets/UserFieldPicker.test.tsx +0 -78
  233. package/src/widgets/VectorField.tsx +0 -29
  234. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
  235. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -142
  236. package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
  237. package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
  238. package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
  239. package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
  240. package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
  241. package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
  242. package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
  243. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
  244. package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
  245. package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
  246. package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
  247. package/src/widgets/_cell-renderer-bridge.ts +0 -19
  248. package/src/widgets/deriveLookupColumns.test.ts +0 -112
  249. package/src/widgets/deriveLookupColumns.ts +0 -108
  250. package/src/widgets/file-value.test.ts +0 -230
  251. package/src/widgets/file-value.ts +0 -246
  252. package/src/widgets/openNativePicker.ts +0 -18
  253. package/src/widgets/personDisplay.test.ts +0 -113
  254. package/src/widgets/personDisplay.ts +0 -92
  255. package/src/widgets/recentLookups.test.ts +0 -45
  256. package/src/widgets/recentLookups.ts +0 -47
  257. package/src/widgets/toDomProps.ts +0 -175
  258. package/src/widgets/types.ts +0 -242
  259. package/src/widgets/useCascadingOptions.ts +0 -46
  260. package/src/widgets/useFieldTranslation.ts +0 -150
  261. package/src/widgets/useRecordQuery.test.ts +0 -174
  262. package/src/widgets/useRecordQuery.ts +0 -264
  263. package/src/widgets/useUploadingSignal.test.tsx +0 -62
  264. package/src/widgets/useUploadingSignal.ts +0 -20
  265. package/src/withFieldCarrier.tsx +0 -75
@@ -1,1291 +0,0 @@
1
- import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
2
- import { cn,
3
- Button,
4
- Input,
5
- Badge,
6
- Avatar,
7
- AvatarFallback,
8
- AvatarImage,
9
- Popover,
10
- PopoverTrigger,
11
- PopoverContent, EmptyValue } from '@object-ui/components';
12
- import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
13
- import { FieldWidgetComponentProps } from './types';
14
- import { toDomProps } from './toDomProps';
15
- import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
16
- import { RecordPickerDialog, lookupFiltersToRecord } from './RecordPickerDialog';
17
- import type { RecordPickerFilterColumn } from './RecordPickerDialog';
18
- import { PeoplePicker } from './PeoplePicker';
19
- import { useRecordQuery } from './useRecordQuery';
20
- import { deriveLookupColumns } from './deriveLookupColumns';
21
- import { getRecordDisplayName } from '@object-ui/core';
22
- import { getRecentLookupIds, pushRecentLookupId } from './recentLookups';
23
- import { getPersonInitials } from './personDisplay';
24
- import { getCellRendererResolver } from './_cell-renderer-bridge';
25
- import { SchemaRendererContext as ImportedSchemaRendererContext, useAction, useHasActionProvider } from '@object-ui/react';
26
- import { useFieldTranslation } from './useFieldTranslation';
27
-
28
- export interface LookupOption {
29
- value: string | number;
30
- label: string;
31
- description?: string;
32
- [key: string]: any;
33
- }
34
-
35
- /** Page size for the quick-select popover typeahead */
36
- const LOOKUP_PAGE_SIZE = 50;
37
-
38
- /**
39
- * SchemaRendererContext is created by @object-ui/react.
40
- * Using a static import to be compatible with Next.js Turbopack SSR.
41
- */
42
- const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
43
-
44
- /**
45
- * A relation whose picker should offer inline "create the referenced record" by
46
- * default. Inline quick-create is a STANDARD capability so a freshly-built app
47
- * isn't a dead end (an empty required picker → you can create the FIRST related
48
- * record right here). Platform/system objects are excluded: the user directory
49
- * (`sys_user` and its bare `user`/`users` aliases) and everything under the
50
- * `sys_`/`cloud_`/`ai_` namespaces are pre-populated plumbing you must not create
51
- * from a field picker. A user-authored business entity (customer, pet, book, …)
52
- * never matches, so it gets the capability.
53
- */
54
- const SYSTEM_REFERENCE_RX = /^(sys_|cloud_|ai_)/;
55
- const USER_DIRECTORY_REFS = new Set(['user', 'users']);
56
- function isUserFacingReference(reference: string | undefined): boolean {
57
- return !!reference && !SYSTEM_REFERENCE_RX.test(reference) && !USER_DIRECTORY_REFS.has(reference);
58
- }
59
-
60
- /**
61
- * Render a record title from a `titleFormat` template (e.g. `{full_name}` or
62
- * `{case_number} - {subject}`). When a templated key resolves to an empty
63
- * value, the surrounding separator (`-/|·,:` plus em/en dashes) is stripped
64
- * so we never produce orphan glyphs like `" - foo"`.
65
- *
66
- * Mirrors the implementation in `@object-ui/plugin-detail`'s
67
- * `resolveDisplayTitle` and `@object-ui/plugin-calendar`'s event-title
68
- * renderer so labels stay consistent across the product.
69
- */
70
- function formatRecordTitle(record: any, titleFormat: string): string | null {
71
- if (!record || typeof record !== 'object' || !titleFormat) return null;
72
- const EMPTY = '\u0000';
73
- const SEP = '[-\\u2013\\u2014|/·,:]';
74
- let any = false;
75
- const raw = titleFormat.replace(/\{([^{}]+)\}/g, (_m, key) => {
76
- const v = (record as any)[key.trim()];
77
- if (v !== null && v !== undefined && v !== '') {
78
- any = true;
79
- return String(v);
80
- }
81
- return EMPTY;
82
- });
83
- if (!any) return null;
84
- const out = raw
85
- .replace(new RegExp(`\\s*${SEP}\\s*${EMPTY}`, 'g'), '')
86
- .replace(new RegExp(`${EMPTY}\\s*${SEP}\\s*`, 'g'), '')
87
- .replace(new RegExp(EMPTY, 'g'), '')
88
- .replace(/\s+/g, ' ')
89
- .trim();
90
- return out || null;
91
- }
92
-
93
- /**
94
- * Map a raw record to a LookupOption using a display field and an id field.
95
- *
96
- * Label precedence (ADR-0079):
97
- * 1. `titleFormat` template (when supplied, derived from the referenced
98
- * object's schema) — e.g. `"Acme - John Doe"`.
99
- * 2. the explicit `displayField` value on the record.
100
- * 3. the unified `@object-ui/core#getRecordDisplayName` against the referenced
101
- * object's schema (`objectDef`) — adds `displayNameField` + type-aware
102
- * field derivation, so a lookup to an object whose name lives in e.g.
103
- * `activity_name` resolves a real name instead of the raw id. We stop short
104
- * of the resolver's `Record #<id>` floor here so the chip still falls
105
- * through to the bare id when nothing nameable exists.
106
- * 4. the legacy hard-coded name list, then the raw id.
107
- */
108
- function recordToOption(
109
- record: any,
110
- displayField: string,
111
- idField: string,
112
- descriptionField?: string,
113
- titleFormat?: string | null,
114
- objectDef?: any,
115
- ): LookupOption {
116
- const val = record[idField] ?? record.id ?? record._id ?? record.externalId;
117
- const templated = titleFormat ? formatRecordTitle(record, titleFormat) : null;
118
-
119
- // Object-level resolver fallback (displayNameField + derivation), excluding
120
- // its id floor so we don't shadow the explicit `String(val)` tail.
121
- let unified: string | undefined;
122
- if (objectDef) {
123
- const resolved = getRecordDisplayName(objectDef, record);
124
- const id = record?.id ?? record?._id;
125
- const isFloor =
126
- resolved === 'Untitled' ||
127
- (id !== null && id !== undefined && resolved === `Record #${id}`);
128
- if (!isFloor) unified = resolved;
129
- }
130
-
131
- const label =
132
- templated ??
133
- record[displayField] ??
134
- unified ??
135
- record.label ??
136
- record.name ??
137
- record.full_name ??
138
- record.title ??
139
- record.subject ??
140
- record.externalId ??
141
- String(val);
142
- const description = descriptionField ? record[descriptionField] : undefined;
143
- return { value: val, label: String(label), description, ...record };
144
- }
145
-
146
- /**
147
- * A reference value can arrive JSON-encoded — e.g. an unresolved external-id
148
- * reference `'{"externalId":"Website Relaunch"}'`. Parse such a string into its
149
- * object form so the inline editor resolves it through the same path as a
150
- * server-`$expand`ed record. Returns null for anything that isn't a JSON object
151
- * string. Mirrors the read cell (`LookupCellRenderer`) so the two stay aligned.
152
- */
153
- function parseReferenceObjectString(v: any): Record<string, any> | null {
154
- if (typeof v !== 'string') return null;
155
- const s = v.trim();
156
- if (!s.startsWith('{') || !s.endsWith('}')) return null;
157
- try {
158
- const parsed = JSON.parse(s);
159
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
160
- } catch {
161
- return null;
162
- }
163
- }
164
-
165
- /**
166
- * Map a LookupColumnDef.type to a filter input type for the filter bar.
167
- * Returns undefined if the field type is not filterable.
168
- */
169
- function mapFieldTypeToFilterType(
170
- fieldType: string,
171
- ): RecordPickerFilterColumn['type'] | undefined {
172
- const mapping: Record<string, RecordPickerFilterColumn['type']> = {
173
- text: 'text',
174
- number: 'number',
175
- currency: 'number',
176
- percent: 'number',
177
- select: 'select',
178
- status: 'select',
179
- date: 'date',
180
- datetime: 'date',
181
- boolean: 'boolean',
182
- };
183
- return mapping[fieldType];
184
- }
185
-
186
- /**
187
- * Lookup field for selecting related records.
188
- * Supports single and multi-select with search.
189
- *
190
- * When a `dataSource` is provided (either via props, via `field.dataSource`,
191
- * or via SchemaRendererContext), the dialog will dynamically load records
192
- * from the referenced object using `DataSource.find()`.
193
- * Falls back to static `options` when no DataSource is available.
194
- */
195
- export function LookupField({ value, onChange, field, readonly, error: fieldError, ...props }: FieldWidgetComponentProps<any>) {
196
- const [isOpen, setIsOpen] = useState(false);
197
- const { t } = useFieldTranslation();
198
- const listboxId = React.useId();
199
-
200
- // Create-new error is local; the popover's fetch state (search/loading/error/
201
- // total/options) is sourced from the shared useRecordQuery kernel below.
202
- const [createError, setCreateError] = useState<string | null>(null);
203
-
204
- // Records selected via RecordPickerDialog (Level 2).
205
- // Stored as LookupOption so that findOption can resolve display labels
206
- // even when the record wasn't part of the Level 1 popover fetch.
207
- const [pickerResolvedRecords, setPickerResolvedRecords] = useState<LookupOption[]>([]);
208
-
209
- // Ids whose hydration attempt has FINISHED — found or not — keyed by
210
- // String(id). Distinguishes "still loading" from "unresolvable" (deleted
211
- // record, fetch failure) so the trigger's hydrating state can't stick
212
- // forever on an id that will never resolve (#3108).
213
- const [hydrationSettled, setHydrationSettled] = useState<Set<string>>(() => new Set());
214
-
215
- // Arrow-key active index (-1 = none)
216
- const [activeIndex, setActiveIndex] = useState(-1);
217
- const listRef = useRef<HTMLDivElement>(null);
218
-
219
- const lookupField = field as any;
220
-
221
- // When rendered via createFieldRenderer wrapper the actual objectSchema field
222
- // metadata (reference_to, display_field, etc.) lives at lookupField.field.
223
- // Unwrap it so lookup-specific properties resolve correctly.
224
- // ObjectStack convention uses `reference` while the types use `reference_to`,
225
- // so we check for both property names.
226
- const innerField = lookupField?.field;
227
- const fieldMeta = (innerField && typeof innerField === 'object' && ('reference_to' in innerField || 'reference' in innerField || 'type' in innerField))
228
- ? innerField
229
- : lookupField;
230
-
231
- const staticOptions: LookupOption[] = fieldMeta?.options || [];
232
- const multiple = fieldMeta?.multiple || false;
233
- const displayField = fieldMeta?.display_field || fieldMeta?.displayField || fieldMeta?.reference_field || 'name';
234
- const descriptionField: string | undefined = fieldMeta?.description_field ?? fieldMeta?.descriptionField;
235
- const idField = fieldMeta?.id_field || 'id';
236
- // ObjectStack convention uses `reference`; types define `reference_to` — support both
237
- const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
238
- // Inline quick-create — a STANDARD capability, default ON for user-facing
239
- // relations: an empty/zero-result picker offers to create the referenced
240
- // record (opening its create form; see handleCreateNew) so the first related
241
- // record can be made right here. An explicit `allowCreate` (either casing)
242
- // wins — set it `false` to opt a field out; system/user-directory references
243
- // are excluded from the default (isUserFacingReference).
244
- const explicitAllowCreate = fieldMeta?.allow_create ?? fieldMeta?.allowCreate;
245
- const allowCreate: boolean =
246
- explicitAllowCreate != null ? !!explicitAllowCreate : isUserFacingReference(referenceTo);
247
-
248
- // Enterprise Record Picker configuration
249
- const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns ?? fieldMeta?.lookupColumns;
250
- const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size ?? fieldMeta?.lookupPageSize;
251
- const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters ?? fieldMeta?.lookupFilters;
252
-
253
- // Search-first PeoplePicker opt-in (user fields). When `picker === 'search'`
254
- // the Level-2 picker is the rich PeoplePicker (avatar rows + selection tray)
255
- // instead of the classic table dialog. `subtitle`/`avatar_field` drive the rows.
256
- const pickerVariant: string | undefined = fieldMeta?.picker;
257
- const subtitleFields: string[] | undefined = fieldMeta?.subtitle;
258
- const avatarField: string = fieldMeta?.avatar_field ?? fieldMeta?.avatarField ?? 'image';
259
-
260
- /**
261
- * Dependent lookups — restrict candidates based on values of *other* fields
262
- * in the same form. Two shapes are accepted:
263
- *
264
- * 1. `depends_on: ['country']` → shorthand. The dependent field value is sent
265
- * as both the filter field and the source field (i.e. `country = ${country}`).
266
- * 2. `depends_on: [{ field: 'country', param: 'country_id' }]` → explicit.
267
- * The remote field name (`param`) can differ from the local field name.
268
- *
269
- * When any dependency is empty, the lookup is gated and the user sees a
270
- * helpful "Select {field} first" hint instead of unfiltered records.
271
- */
272
- const dependsOn = useMemo<Array<{ field: string; param: string }>>(() => {
273
- const raw = fieldMeta?.depends_on ?? fieldMeta?.dependsOn;
274
- if (!raw) return [];
275
- if (Array.isArray(raw)) {
276
- return raw.map((d: any) =>
277
- typeof d === 'string' ? { field: d, param: d } : { field: d.field, param: d.param ?? d.field },
278
- );
279
- }
280
- return [];
281
- }, [fieldMeta?.depends_on, fieldMeta?.dependsOn]);
282
-
283
- /**
284
- * The gate sentence's `{{fields}}` — the controlling fields named the way the
285
- * user sees them on the form, not the way the metadata spells them.
286
- *
287
- * `depends_on` holds API names, and this used to interpolate them straight
288
- * into the sentence, so every locale — `en` included — read "Select
289
- * crm_account first" (objectstack#5407): an internal identifier in the UI,
290
- * not merely an untranslated word. The host form supplies the name→label map
291
- * (`dependsOnLabels`); a name it doesn't cover falls back to itself, so a
292
- * standalone widget with no host renders exactly what it did before.
293
- */
294
- const dependsOnLabelsProp = props.dependsOnLabels;
295
- const dependsOnFieldsText = useMemo(
296
- () => dependsOn.map((d) => dependsOnLabelsProp?.[d.field] || d.field).join(', '),
297
- [dependsOn, dependsOnLabelsProp],
298
- );
299
-
300
- // Resolve dependent field values from explicit prop or SchemaRendererContext.data
301
- const dependentValuesProp = props.dependentValues;
302
-
303
- // Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
304
- const ctx = useContext(SchemaRendererContext);
305
- const contextDataSource = ctx?.dataSource ?? null;
306
- const dataSource: DataSource | null =
307
- (props.dataSource as DataSource | null | undefined) ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
308
-
309
- /** Resolve dependent values from the explicit prop (preferred), the form-data
310
- * context provided by @object-ui/react, or finally `ctx.data` (record scope). */
311
- const resolvedDependentValues: Record<string, any> = useMemo(() => {
312
- if (dependentValuesProp) return dependentValuesProp;
313
- return (ctx?.formValues ?? ctx?.data ?? {}) as Record<string, any>;
314
- }, [dependentValuesProp, ctx?.formValues, ctx?.data]);
315
-
316
- /** True when at least one dependency is missing (empty). The picker is gated
317
- * in that state so we never issue an unfiltered query that ignores the
318
- * user's earlier choices. */
319
- const dependenciesMissing = useMemo(() => {
320
- if (dependsOn.length === 0) return false;
321
- return dependsOn.some(({ field }) => {
322
- const v = resolvedDependentValues[field];
323
- return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
324
- });
325
- }, [dependsOn, resolvedDependentValues]);
326
-
327
- const hasDataSource = dataSource != null && typeof dataSource.find === 'function' && !!referenceTo;
328
-
329
- // Fetch the referenced object's schema so we can render option labels via
330
- // its `titleFormat` template (e.g. `{full_name}`, `{case_number} - {subject}`).
331
- // Without this the label fell back to a non-existent `name` field and
332
- // ultimately to the raw record id.
333
- const [refObjectSchema, setRefObjectSchema] = useState<any>(null);
334
- useEffect(() => {
335
- if (!dataSource || !referenceTo) return;
336
- const getSchema = (dataSource as any).getObjectSchema;
337
- if (typeof getSchema !== 'function') return;
338
- let alive = true;
339
- Promise.resolve(getSchema.call(dataSource, referenceTo))
340
- .then((s: any) => { if (alive) setRefObjectSchema(s); })
341
- .catch(() => { /* fall back to displayField chain */ });
342
- return () => { alive = false; };
343
- }, [dataSource, referenceTo]);
344
-
345
- const refTitleFormat: string | null = useMemo(() => {
346
- const raw = refObjectSchema?.titleFormat;
347
- if (typeof raw === 'string') return raw;
348
- if (raw && typeof raw === 'object' && typeof raw.source === 'string') return raw.source;
349
- return null;
350
- }, [refObjectSchema]);
351
-
352
- /**
353
- * Picker columns. Honour explicit `lookup_columns` when authored; otherwise
354
- * derive a multi-column, disambiguating set from the referenced object's
355
- * schema so every lookup gets a useful picker with zero field-level config.
356
- */
357
- const pickerColumns = useMemo<Array<string | LookupColumnDef> | undefined>(() => {
358
- if (lookupColumns && lookupColumns.length > 0) return lookupColumns;
359
- const derived = deriveLookupColumns(refObjectSchema, { displayField });
360
- return derived.length > 0 ? derived : undefined;
361
- }, [lookupColumns, refObjectSchema, displayField]);
362
-
363
- /**
364
- * Secondary line under each quick-select option. Honour explicit
365
- * `description_field`; otherwise reuse the first derived non-display column so
366
- * the inline popover also benefits from the richer schema.
367
- */
368
- const effectiveDescriptionField = useMemo<string | undefined>(() => {
369
- if (descriptionField) return descriptionField;
370
- const extra = pickerColumns?.find((c) => (typeof c === 'string' ? c : c.field) !== displayField);
371
- if (!extra) return undefined;
372
- return typeof extra === 'string' ? extra : extra.field;
373
- }, [descriptionField, pickerColumns, displayField]);
374
-
375
- // Derive filter-bar columns from any typed picker columns.
376
- const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
377
- if (!pickerColumns) return undefined;
378
- const cols: RecordPickerFilterColumn[] = [];
379
- for (const c of pickerColumns) {
380
- if (typeof c === 'object' && c.type) {
381
- const filterType = mapFieldTypeToFilterType(c.type);
382
- if (filterType) cols.push({ field: c.field, label: c.label, type: filterType });
383
- }
384
- }
385
- return cols.length > 0 ? cols : undefined;
386
- }, [pickerColumns]);
387
-
388
- // Optional create-new callback
389
- const onCreateNew: ((searchQuery: string) => void) | undefined =
390
- props.onCreateNew ?? lookupField?.onCreateNew;
391
-
392
- // State for the full Record Picker dialog (Level 2)
393
- const [isPickerOpen, setIsPickerOpen] = useState(false);
394
-
395
- // Dependent-lookup chain as a hard QueryParams.$filter record. Shared by
396
- // EVERY candidate surface — quick-select popover, Level-2 table picker and
397
- // the search-first PeoplePicker — so no picker can bypass the cascade
398
- // (#2215: the table picker used to list the full unfiltered set).
399
- const dependentFilter = useMemo<Record<string, any> | undefined>(() => {
400
- const f: Record<string, any> = {};
401
- for (const { field, param } of dependsOn) {
402
- const v = resolvedDependentValues[field];
403
- if (v === undefined || v === null || v === '') continue;
404
- f[param] = typeof v === 'number' ? v : String(v);
405
- }
406
- return Object.keys(f).length > 0 ? f : undefined;
407
- }, [dependsOn, resolvedDependentValues]);
408
-
409
- // Determine which options to display
410
- // Quick-select popover fetch — the shared record-query kernel (same one the
411
- // Record Picker dialog and PeoplePicker use). Filter = dependent-lookup chain
412
- // + base lookupFilters, so the popover matches the full picker.
413
- const popoverFilter = useMemo<Record<string, any> | undefined>(() => {
414
- const f: Record<string, any> = {
415
- ...(lookupFilters && lookupFilters.length > 0 ? lookupFiltersToRecord(lookupFilters) : {}),
416
- ...(dependentFilter ?? {}),
417
- };
418
- return Object.keys(f).length > 0 ? f : undefined;
419
- }, [dependentFilter, lookupFilters]);
420
-
421
- const popoverQuery = useRecordQuery({
422
- dataSource,
423
- objectName: referenceTo,
424
- enabled: isOpen && hasDataSource && !dependenciesMissing,
425
- pageSize: LOOKUP_PAGE_SIZE,
426
- filter: popoverFilter,
427
- });
428
-
429
- // Re-source the popover's fetch state from the kernel; all existing read sites
430
- // (searchQuery / loading / error / totalCount / fetchedOptions) stay unchanged.
431
- const searchQuery = popoverQuery.search;
432
- const loading = popoverQuery.loading;
433
- const totalCount = popoverQuery.total;
434
- const error = popoverQuery.error ?? createError;
435
- const fetchedOptions = useMemo(
436
- () =>
437
- popoverQuery.records.map(r =>
438
- recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema),
439
- ),
440
- [popoverQuery.records, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
441
- );
442
-
443
- const allOptions = hasDataSource ? fetchedOptions : staticOptions;
444
-
445
- // For static options, filter locally based on search
446
- const filteredOptions = useMemo(() => {
447
- if (hasDataSource) return allOptions;
448
- if (!searchQuery) return allOptions;
449
- const q = searchQuery.toLowerCase();
450
- return allOptions.filter(opt =>
451
- opt.label.toLowerCase().includes(q) ||
452
- (opt.description && opt.description.toLowerCase().includes(q))
453
- );
454
- }, [hasDataSource, allOptions, searchQuery]);
455
-
456
- // Reset active index when options change
457
- useEffect(() => {
458
- setActiveIndex(-1);
459
- }, [filteredOptions.length]);
460
-
461
- // Reset the keyboard cursor when the popover closes. Fetch state (records,
462
- // search, error, total) is owned by `popoverQuery` and resets automatically
463
- // when it becomes disabled (via `enabled`), including its debounced search.
464
- useEffect(() => {
465
- if (!isOpen) setActiveIndex(-1);
466
- }, [isOpen]);
467
-
468
- // Search is the kernel's debounced setter.
469
- const handleSearchChange = useCallback(
470
- (query: string) => popoverQuery.setSearch(query),
471
- [popoverQuery.setSearch],
472
- );
473
-
474
- /**
475
- * Hydrate the picker's display when the field already has a value (e.g.
476
- * edit-mode load, prefill via query-string from a related-list "+ New")
477
- * but no option resolves it yet. Fetches the referenced record(s) via
478
- * the DataSource and caches them in `pickerResolvedRecords` so the chip
479
- * shows a friendly label instead of an empty placeholder.
480
- */
481
- useEffect(() => {
482
- if (!hasDataSource || !dataSource || !referenceTo) return;
483
- const raw: any[] = multiple
484
- ? Array.isArray(value) ? value : []
485
- : value != null && value !== '' ? [value] : [];
486
- // Expanded-reference values (server `$expand`, or their JSON-encoded string
487
- // form) already carry their display fields and resolve directly in
488
- // `resolveSelectedOption` — only bare ids need a fetch. Passing an object (or
489
- // a JSON string) to `findOne` would query for a bogus id and leave the
490
- // trigger stuck on the placeholder.
491
- const ids = raw.filter(
492
- (v) => v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v),
493
- );
494
- if (!ids.length) return;
495
- // Only fetch records we haven't resolved yet.
496
- const unresolved = ids.filter((v) => !findOption(v));
497
- if (!unresolved.length) return;
498
-
499
- let cancelled = false;
500
- (async () => {
501
- try {
502
- const fetched: LookupOption[] = [];
503
- // Single id: the pre-existing cheap paths — a primary-id `findOne`
504
- // GET, or an equality filter when the field commits a different
505
- // column (`id_field: 'name'` — e.g. position machine names,
506
- // objectstack #3508).
507
- if (unresolved.length === 1) {
508
- const id = unresolved[0];
509
- if (typeof (dataSource as any).findOne === 'function' && idField === 'id') {
510
- const rec = await (dataSource as any).findOne(referenceTo, id);
511
- if (rec) fetched.push(recordToOption(rec, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
512
- } else {
513
- const res = await dataSource.find(referenceTo, {
514
- $filter: { [idField]: id },
515
- $top: 1,
516
- } as QueryParams);
517
- const rows = (res as any)?.data ?? res ?? [];
518
- if (rows[0]) fetched.push(recordToOption(rows[0], displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
519
- }
520
- } else {
521
- // SEVERAL unresolved ids: one `$in` query per chunk. A multi-value
522
- // field can hold dozens of ids, and the old serial findOne-per-id
523
- // took seconds while the trigger sat on the empty "Select…"
524
- // placeholder (#3108).
525
- // Chunked to LOOKUP_PAGE_SIZE so no request exceeds the page size
526
- // a server may cap `$top` at; chunks run in parallel.
527
- const chunks: any[][] = [];
528
- for (let i = 0; i < unresolved.length; i += LOOKUP_PAGE_SIZE) {
529
- chunks.push(unresolved.slice(i, i + LOOKUP_PAGE_SIZE));
530
- }
531
- const results = await Promise.all(
532
- chunks.map((chunk) =>
533
- dataSource.find(referenceTo, {
534
- $filter: { [idField]: { $in: chunk } },
535
- $top: chunk.length,
536
- } as QueryParams),
537
- ),
538
- );
539
- for (const res of results) {
540
- const rows = (res as any)?.data ?? res ?? [];
541
- if (!Array.isArray(rows)) continue;
542
- for (const row of rows) {
543
- fetched.push(recordToOption(row, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
544
- }
545
- }
546
- }
547
- if (!cancelled && fetched.length) {
548
- setPickerResolvedRecords((prev) => {
549
- const map = new Map(prev.map((o) => [o.value, o]));
550
- for (const o of fetched) map.set(o.value, o);
551
- return Array.from(map.values());
552
- });
553
- }
554
- } catch {
555
- // Ignore — chip will fall back to showing the raw id.
556
- } finally {
557
- // Mark the attempt settled — found or not — so `hydrating` below
558
- // clears even for ids that no longer resolve (deleted records,
559
- // fetch failures) instead of spinning forever.
560
- if (!cancelled) {
561
- setHydrationSettled((prev) => {
562
- const next = new Set(prev);
563
- for (const id of unresolved) next.add(String(id));
564
- return next;
565
- });
566
- }
567
- }
568
- })();
569
- return () => {
570
- cancelled = true;
571
- };
572
- // eslint-disable-next-line react-hooks/exhaustive-deps
573
- }, [value, hasDataSource, referenceTo, displayField, idField, effectiveDescriptionField, multiple]);
574
-
575
- // Get selected option(s) — check static, fetched, and picker-resolved options
576
- const findOption = useCallback(
577
- (v: any): LookupOption | undefined => {
578
- return (
579
- staticOptions.find(opt => opt.value === v) ??
580
- fetchedOptions.find(opt => opt.value === v) ??
581
- pickerResolvedRecords.find(opt => opt.value === v)
582
- );
583
- },
584
- [staticOptions, fetchedOptions, pickerResolvedRecords],
585
- );
586
-
587
- // String-coerced fallback for `findOption` — matches the read cell's tolerant
588
- // `String(a) === String(b)` comparison so a numeric cell value still resolves
589
- // against a string-keyed option (and vice versa). Only consulted when the
590
- // strict match misses, so homogeneous option lists are unaffected.
591
- const findOptionLoose = useCallback(
592
- (v: any): LookupOption | undefined => {
593
- const key = String(v);
594
- return (
595
- staticOptions.find(opt => String(opt.value) === key) ??
596
- fetchedOptions.find(opt => String(opt.value) === key) ??
597
- pickerResolvedRecords.find(opt => String(opt.value) === key)
598
- );
599
- },
600
- [staticOptions, fetchedOptions, pickerResolvedRecords],
601
- );
602
-
603
- // Collapse an expanded-reference value (the related record object returned by
604
- // server `$expand`) to its bare id — used for option matching / highlighting.
605
- const normalizeId = useCallback(
606
- (raw: any): any => {
607
- const obj = raw != null && typeof raw === 'object' ? raw : parseReferenceObjectString(raw);
608
- return obj ? (obj[idField] ?? obj.id ?? obj._id ?? obj.externalId) : raw;
609
- },
610
- [idField],
611
- );
612
-
613
- // Level-2 pickers (PeoplePicker / RecordPickerDialog) operate on bare ids —
614
- // their seed queries and selected-row matching compare against `idField`
615
- // values. Collapse any `$expand`-ed record objects (passed through
616
- // uncollapsed by the inline editor, objectui#2572) before handing the value
617
- // down, or the seed fetch would query for a bogus object-shaped id.
618
- const pickerValue = useMemo(
619
- () => (multiple ? (Array.isArray(value) ? value.map(normalizeId) : []) : normalizeId(value)),
620
- [multiple, value, normalizeId],
621
- );
622
-
623
- // Resolve a raw field value into its display option. An expanded-reference
624
- // object is mapped directly (mirroring the read cell's display-name path) so
625
- // the inline editor shows the record's name instead of the placeholder; a bare
626
- // id resolves through the static / fetched / picker-hydrated option lists.
627
- const resolveSelectedOption = useCallback(
628
- (raw: any): LookupOption | undefined => {
629
- if (raw == null || raw === '') return undefined;
630
- // An expanded-reference object (server `$expand`) — or its JSON-encoded
631
- // string form, e.g. an external-id reference `'{"externalId":"…"}'` — is
632
- // mapped directly, mirroring the read cell (`LookupCellRenderer`).
633
- const asObject = typeof raw === 'object' ? raw : parseReferenceObjectString(raw);
634
- if (asObject) {
635
- return recordToOption(asObject, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
636
- }
637
- // Bare id: strict match first, then a String()-coerced fallback so a
638
- // numeric cell value still resolves against a string-keyed option (and
639
- // vice versa) — matching the read cell's tolerant comparison.
640
- return findOption(raw) ?? findOptionLoose(raw);
641
- },
642
- [findOption, findOptionLoose, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema],
643
- );
644
-
645
- // A value can hold bare ids that no option list resolves YET — the batch
646
- // hydration above is still in flight. While any such id is pending, the
647
- // trigger must not present the field as empty: a 41-value lookup rendered
648
- // the bare "Select…" placeholder for seconds, indistinguishable from
649
- // having no value at all (#3108).
650
- const hydrating = useMemo(() => {
651
- if (!hasDataSource) return false;
652
- const raw: any[] = multiple
653
- ? Array.isArray(value) ? value : []
654
- : value != null && value !== '' ? [value] : [];
655
- return raw.some(
656
- (v) =>
657
- v != null && v !== '' && typeof v !== 'object' && !parseReferenceObjectString(v) &&
658
- !findOption(v) && !findOptionLoose(v) &&
659
- !hydrationSettled.has(String(v)),
660
- );
661
- }, [hasDataSource, multiple, value, findOption, findOptionLoose, hydrationSettled]);
662
-
663
- // Committed-value count. During hydration the resolved-option count
664
- // undercounts (unresolved ids are filtered out below), so the trigger
665
- // shows this raw count instead.
666
- const rawSelectedCount = multiple
667
- ? (Array.isArray(value) ? value : []).filter((v) => v != null && v !== '').length
668
- : value != null && value !== '' ? 1 : 0;
669
-
670
- const selectedOptions = multiple
671
- ? (Array.isArray(value) ? value : []).map(resolveSelectedOption).filter(Boolean)
672
- : value ? [resolveSelectedOption(value)].filter(Boolean) : [];
673
-
674
- // Optional: receive the FULL selected record (not just its id) so a host can
675
- // auto-fill sibling fields from it — e.g. a line-item grid copying a product's
676
- // unit_price/description when the item is chosen. When provided (single
677
- // select), it drives the update and the host owns the resulting value change.
678
- const onSelectRecord = props.onSelectRecord;
679
-
680
- const handleSelect = useCallback(
681
- (option: LookupOption) => {
682
- // Cache the picked option so its label resolves synchronously and durably,
683
- // independent of the popover's `fetchedOptions` (which the editor may have
684
- // remounted away, or which a slow/contended re-render hasn't surfaced yet —
685
- // the intermittent CI failure where a just-picked lookup showed no label,
686
- // #2150). `selectedOptions` consults `pickerResolvedRecords` in `findOption`.
687
- if (option && option.value != null) {
688
- setPickerResolvedRecords((prev) => {
689
- const map = new Map(prev.map((o) => [o.value, o]));
690
- map.set(option.value, option);
691
- return Array.from(map.values());
692
- });
693
- }
694
- if (multiple) {
695
- // Normalise any expanded-reference objects to bare ids so toggling
696
- // compares like-for-like and always persists ids (never mixed shapes).
697
- const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
698
- const isSelected = currentValues.includes(option.value);
699
-
700
- if (isSelected) {
701
- onChange(currentValues.filter((v: any) => v !== option.value));
702
- } else {
703
- if (referenceTo) pushRecentLookupId(referenceTo, option.value);
704
- onChange([...currentValues, option.value]);
705
- }
706
- } else {
707
- if (referenceTo) pushRecentLookupId(referenceTo, option.value);
708
- if (onSelectRecord) onSelectRecord(option);
709
- else onChange(option.value);
710
- setIsOpen(false);
711
- }
712
- },
713
- [multiple, value, onChange, onSelectRecord, referenceTo, normalizeId],
714
- );
715
-
716
- const handleRemove = (optionValue: any) => {
717
- if (multiple) {
718
- const currentValues = (Array.isArray(value) ? value : []).map(normalizeId);
719
- onChange(currentValues.filter((v: any) => v !== optionValue));
720
- } else {
721
- onChange(null);
722
- }
723
- };
724
-
725
- // Callback from RecordPickerDialog — caches selected records so that
726
- // findOption can resolve display labels after the dialog closes.
727
- const handlePickerSelectRecords = useCallback(
728
- (records: any[]) => {
729
- const mapped = records.map(r => recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
730
- if (referenceTo) mapped.forEach((o) => pushRecentLookupId(referenceTo, o.value));
731
- setPickerResolvedRecords(mapped);
732
- },
733
- [displayField, idField, effectiveDescriptionField, refTitleFormat, referenceTo],
734
- );
735
-
736
- // ── Recently-used, quick-create, combined option list ────────────────────
737
- const [recentOptions, setRecentOptions] = useState<LookupOption[]>([]);
738
- useEffect(() => {
739
- if (!isOpen || !hasDataSource || !dataSource || !referenceTo || searchQuery) return;
740
- const ids = getRecentLookupIds(referenceTo);
741
- if (!ids.length) { setRecentOptions([]); return; }
742
- let cancelled = false;
743
- (async () => {
744
- try {
745
- const recs: LookupOption[] = [];
746
- for (const id of ids) {
747
- const cached = findOption(id);
748
- if (cached) { recs.push(cached); continue; }
749
- if (typeof (dataSource as any).findOne === 'function') {
750
- const r = await (dataSource as any).findOne(referenceTo, id);
751
- if (r) recs.push(recordToOption(r, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema));
752
- }
753
- }
754
- if (!cancelled) setRecentOptions(recs);
755
- } catch { if (!cancelled) setRecentOptions([]); }
756
- })();
757
- return () => { cancelled = true; };
758
- // eslint-disable-next-line react-hooks/exhaustive-deps
759
- }, [isOpen, hasDataSource, referenceTo, searchQuery]);
760
-
761
- // Recently-used first (only before the user types), then live results — one
762
- // de-duped list that drives BOTH rendering and arrow-key navigation.
763
- const recentCount = (!searchQuery && hasDataSource) ? recentOptions.length : 0;
764
- const visibleOptions = useMemo(() => {
765
- if (searchQuery || !hasDataSource || recentOptions.length === 0) return filteredOptions;
766
- const recentIds = new Set(recentOptions.map((o) => o.value));
767
- return [...recentOptions, ...filteredOptions.filter((o) => !recentIds.has(o.value))];
768
- }, [searchQuery, hasDataSource, recentOptions, filteredOptions]);
769
- useEffect(() => { setActiveIndex(-1); }, [visibleOptions.length]);
770
-
771
- const [creating, setCreating] = useState(false);
772
- // Open the referenced object's create form through the ActionProvider's modal
773
- // handler (when a form host wired one). Safe outside a provider — useAction
774
- // returns a local runner and useHasActionProvider is false.
775
- const { execute } = useAction();
776
- const hasActionProvider = useHasActionProvider();
777
- const canCreate = !!onCreateNew || (allowCreate && (hasActionProvider || hasDataSource));
778
- const handleCreateNew = useCallback(
779
- async (q: string) => {
780
- const label = (q || '').trim();
781
- if (onCreateNew) { onCreateNew(label); setIsOpen(false); return; }
782
- if (!allowCreate || !referenceTo) return;
783
- setCreateError(null);
784
-
785
- // Preferred path — open the referenced object's FULL create form so the
786
- // user fills every required field themselves, then select the record they
787
- // created. This is the standard "create related record" behaviour: it
788
- // works for ANY object (not only ones whose sole required field is the
789
- // title) and turns an empty required picker from a dead end into a way to
790
- // author the first related record.
791
- if (hasActionProvider) {
792
- setIsOpen(false); // close the picker popover first
793
- // Defer opening the create dialog until the popover has fully closed and
794
- // returned focus to the field trigger. Two nested-modal bugs come from
795
- // opening it in the same tick as the triggering click:
796
- // 1) the just-mounted Dialog treats this click's release (and the
797
- // popover's own dismiss) as an outside-interaction and flashes shut
798
- // on the FIRST click (works on the second);
799
- // 2) with the popover already gone, the "+ create" button that Radix
800
- // would return focus to is unmounted, so when the nested modal later
801
- // closes focus leaks to <body> and dismisses the PARENT form's dialog
802
- // too ("Cancel closes both").
803
- // A macrotask runs after Radix's focus-return layout effects, so focus is
804
- // safely back on the still-mounted field trigger before the Dialog opens.
805
- await new Promise((r) => setTimeout(r, 0));
806
- const result: any = await execute({
807
- type: 'modal',
808
- modal: { objectName: referenceTo, mode: 'create' },
809
- } as any);
810
- if (result?.success && result.data) {
811
- const opt = recordToOption(result.data, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
812
- setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
813
- handleSelect(opt);
814
- return;
815
- }
816
- // `success` + an echoed `modal` schema means no modal handler was wired
817
- // in this tree → fall through to the legacy inline create. Anything else
818
- // (cancel / no data) means the user backed out → stop.
819
- if (!(result?.success && result.modal)) return;
820
- }
821
-
822
- // Fallback (no modal handler in scope): the legacy one-field inline create
823
- // from the typed text. Best-effort; surfaces any validation error inline.
824
- if (!dataSource || !label) return;
825
- setCreating(true);
826
- try {
827
- const created = await (dataSource as any).create(referenceTo, { [displayField]: label });
828
- const opt = recordToOption(created, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema);
829
- setPickerResolvedRecords((prev) => [opt, ...prev.filter((o) => o.value !== opt.value)]);
830
- handleSelect(opt);
831
- } catch (err) {
832
- setCreateError(err instanceof Error ? err.message : String(err));
833
- } finally {
834
- setCreating(false);
835
- }
836
- },
837
- [onCreateNew, allowCreate, referenceTo, hasActionProvider, execute, dataSource, displayField, idField, effectiveDescriptionField, refTitleFormat, refObjectSchema, handleSelect],
838
- );
839
-
840
- // Compact one-line preview of an option's extra (non-display) columns —
841
- // shown as a native tooltip so users can disambiguate without opening it.
842
- const previewOf = useCallback(
843
- (option: LookupOption): string | undefined => {
844
- if (!pickerColumns || pickerColumns.length === 0) return undefined;
845
- const parts: string[] = [];
846
- for (const c of pickerColumns) {
847
- const f = typeof c === 'string' ? c : c.field;
848
- if (f === displayField) continue;
849
- const v = (option as any)[f];
850
- if (v === null || v === undefined || v === '') continue;
851
- const lbl = typeof c === 'string' ? f : (c.label || f);
852
- const text = typeof v === 'object' ? (v.name ?? v.label ?? JSON.stringify(v)) : v;
853
- parts.push(`${lbl}: ${text}`);
854
- }
855
- return parts.length ? parts.join(' · ') : undefined;
856
- },
857
- [pickerColumns, displayField],
858
- );
859
-
860
- // Keyboard handler for the search input — arrow keys + Enter
861
- const handleSearchKeyDown = useCallback(
862
- (e: React.KeyboardEvent) => {
863
- if (e.key === 'ArrowDown') {
864
- e.preventDefault();
865
- setActiveIndex(prev =>
866
- prev < visibleOptions.length - 1 ? prev + 1 : prev,
867
- );
868
- } else if (e.key === 'ArrowUp') {
869
- e.preventDefault();
870
- setActiveIndex(prev => (prev > 0 ? prev - 1 : 0));
871
- } else if (e.key === 'Enter') {
872
- e.preventDefault();
873
- if (activeIndex >= 0 && activeIndex < visibleOptions.length) {
874
- handleSelect(visibleOptions[activeIndex]);
875
- }
876
- }
877
- },
878
- [visibleOptions, activeIndex, handleSelect],
879
- );
880
-
881
- // Scroll active item into view
882
- useEffect(() => {
883
- if (activeIndex >= 0 && listRef.current) {
884
- const el = listRef.current.querySelector(`[data-lookup-index="${activeIndex}"]`);
885
- el?.scrollIntoView({ block: 'nearest' });
886
- }
887
- }, [activeIndex]);
888
-
889
- if (readonly) {
890
- if (!selectedOptions.length) {
891
- if (hydrating) {
892
- return (
893
- <span
894
- className="inline-flex items-center gap-1.5 text-sm text-muted-foreground"
895
- data-testid="lookup-hydrating"
896
- >
897
- <Loader2 className="size-3.5 animate-spin" />
898
- {multiple ? t('table.selected', { count: rawSelectedCount }) : t('lookup.loading')}
899
- </span>
900
- );
901
- }
902
- return <EmptyValue />;
903
- }
904
-
905
- if (multiple) {
906
- return (
907
- <div className="flex flex-wrap gap-1">
908
- {selectedOptions.map((opt, idx) => (
909
- <Badge key={idx} variant="outline">
910
- {opt?.label || opt?.[displayField]}
911
- </Badge>
912
- ))}
913
- </div>
914
- );
915
- }
916
-
917
- return (
918
- <span className="text-sm">
919
- {selectedOptions[0]?.label || selectedOptions[0]?.[displayField]}
920
- </span>
921
- );
922
- }
923
-
924
- // Compact mode (e.g. inside a line-item grid cell): show the selected value
925
- // INSIDE a borderless trigger on a single line — no chip stacked above a
926
- // separate "Select…" button (which double-stacks and wastes the row height).
927
- const compact = !!props.compact;
928
- const singleSelectedLabel = selectedOptions[0]?.label || selectedOptions[0]?.[displayField];
929
-
930
- // Shared field trigger — the anchor for either the inline PeoplePicker
931
- // (search fields) or the classic quick-select popover. No onClick: the Radix
932
- // trigger it is slotted into (PopoverTrigger / SheetTrigger) owns open/close.
933
- //
934
- // DOM pass-through onto this button — the widget's real focusable control —
935
- // per objectui#3318. `name` is withheld: no native control here takes part
936
- // in form submission, and a stray `name` on a button invites exactly the
937
- // accidental-submitter semantics #3306 kept it off the SelectTrigger for.
938
- const { name: _domName, ...triggerDomProps } = toDomProps(props);
939
- const triggerButton = (
940
- <Button
941
- {...triggerDomProps}
942
- variant="outline"
943
- className={cn(
944
- // max-w-full: in a BLOCK parent (detail-section inline edit) the
945
- // inline-flex button is content-sized — a long selected label pushed
946
- // it past the card edge; bounding it lets the inner `truncate` span
947
- // clip instead (objectui#3466). flex-1/min-w-0 keep handling the
948
- // flex-parent (form row) case.
949
- 'min-w-0 max-w-full flex-1 justify-start text-left font-normal',
950
- compact && 'h-8 rounded-none border-0 bg-transparent px-2 shadow-none focus-visible:ring-1 focus-visible:ring-ring/60',
951
- )}
952
- type="button"
953
- disabled={dependenciesMissing || props.disabled}
954
- data-testid={dependenciesMissing ? 'lookup-trigger-gated' : ((props.name || lookupField?.name) ? `lookup-trigger-${props.name || lookupField.name}` : 'lookup-trigger')}
955
- title={dependenciesMissing
956
- ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
957
- : undefined}
958
- // AFTER the spread so this widget's own computation wins (#3222):
959
- // `fieldError` is the published validation slot — NOT the popover's
960
- // fetch error, which is a widget-internal state named `error` below.
961
- aria-invalid={!!fieldError}
962
- >
963
- {hydrating ? (
964
- <Loader2
965
- className={cn('size-4 shrink-0 animate-spin text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')}
966
- data-testid="lookup-hydrating"
967
- />
968
- ) : (
969
- <Search className={cn('size-4 shrink-0 text-muted-foreground', compact ? 'mr-1.5' : 'mr-2')} />
970
- )}
971
- <span className={cn('truncate', compact && selectedOptions.length === 0 && 'text-muted-foreground')}>
972
- {dependenciesMissing
973
- ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
974
- : hydrating
975
- // The value EXISTS but its labels are still loading — say so
976
- // instead of the empty placeholder (#3108).
977
- ? multiple
978
- ? t('table.selected', { count: rawSelectedCount })
979
- : t('lookup.loading')
980
- : compact && !multiple && selectedOptions.length > 0
981
- ? singleSelectedLabel
982
- : selectedOptions.length === 0
983
- ? lookupField?.placeholder || t('common.select')
984
- : multiple ? t('table.selected', { count: selectedOptions.length }) : t('common.select')}
985
- </span>
986
- </Button>
987
- );
988
-
989
- return (
990
- <div className={compact ? '' : 'space-y-2'}>
991
- {/* Selected values display (full mode only — compact shows it in-trigger) */}
992
- {selectedOptions.length > 0 && !compact && (
993
- <div className="flex flex-wrap gap-1">
994
- {selectedOptions.map((opt, idx) => {
995
- const chipLabel = opt?.label || opt?.[displayField];
996
- // Search-first (people) fields show avatar chips; classic lookups
997
- // keep the plain text Badge.
998
- if (pickerVariant === 'search') {
999
- const avatarUrl = (opt as any)?.[avatarField] || (opt as any)?.image;
1000
- return (
1001
- <span
1002
- key={idx}
1003
- data-testid="people-field-chip"
1004
- className="inline-flex items-center gap-1.5 rounded-full border bg-background py-0.5 pl-0.5 pr-1.5 text-sm"
1005
- >
1006
- <Avatar className="size-6 shrink-0">
1007
- {avatarUrl && <AvatarImage src={avatarUrl} alt={String(chipLabel || '')} />}
1008
- <AvatarFallback className="text-[10px]">
1009
- {getPersonInitials(String(chipLabel || ''))}
1010
- </AvatarFallback>
1011
- </Avatar>
1012
- <span className="max-w-[10rem] truncate">{chipLabel}</span>
1013
- <button
1014
- onClick={() => handleRemove(opt?.value)}
1015
- className="rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
1016
- type="button"
1017
- aria-label={t('lookup.remove', { label: chipLabel })}
1018
- >
1019
- <X className="size-3" />
1020
- </button>
1021
- </span>
1022
- );
1023
- }
1024
- return (
1025
- <Badge key={idx} variant="outline" className="gap-1">
1026
- {chipLabel}
1027
- <button
1028
- onClick={() => handleRemove(opt?.value)}
1029
- className="ml-1 hover:text-destructive"
1030
- type="button"
1031
- aria-label={t('lookup.remove', { label: chipLabel })}
1032
- >
1033
- <X className="size-3" />
1034
- </button>
1035
- </Badge>
1036
- );
1037
- })}
1038
- </div>
1039
- )}
1040
-
1041
- {/* Field control: search-first inline combobox (anchored dropdown / mobile
1042
- sheet), else the classic quick-select popover. */}
1043
- {pickerVariant === 'search' && hasDataSource && dataSource && referenceTo ? (
1044
- <PeoplePicker
1045
- inline
1046
- trigger={triggerButton}
1047
- open={isPickerOpen}
1048
- onOpenChange={setIsPickerOpen}
1049
- title={lookupField?.label || t('common.select')}
1050
- multiple={multiple}
1051
- dataSource={dataSource}
1052
- objectName={referenceTo}
1053
- displayField={displayField}
1054
- idField={idField}
1055
- subtitleFields={subtitleFields}
1056
- avatarField={avatarField}
1057
- pageSize={lookupPageSize}
1058
- value={pickerValue}
1059
- onSelect={onChange}
1060
- onSelectRecords={handlePickerSelectRecords}
1061
- lookupFilters={lookupFilters}
1062
- baseFilter={dependentFilter}
1063
- />
1064
- ) : (
1065
- <div className="flex items-center gap-1.5">
1066
- <Popover
1067
- open={isOpen}
1068
- onOpenChange={(o) => {
1069
- if (!dependenciesMissing) setIsOpen(o);
1070
- }}
1071
- >
1072
- <PopoverTrigger asChild>
1073
- {triggerButton}
1074
- </PopoverTrigger>
1075
- <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
1076
- {/* Search input */}
1077
- <div className="p-2">
1078
- <div className="relative">
1079
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
1080
- <Input
1081
- placeholder={t('common.search') + '...'}
1082
- value={searchQuery}
1083
- onChange={(e) => handleSearchChange(e.target.value)}
1084
- onKeyDown={handleSearchKeyDown}
1085
- className="w-full pl-9 h-8 text-sm"
1086
- role="combobox"
1087
- aria-autocomplete="list"
1088
- aria-controls={listboxId}
1089
- aria-expanded={isOpen}
1090
- aria-activedescendant={activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined}
1091
- />
1092
- {loading && (
1093
- <Loader2
1094
- className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
1095
- data-testid="lookup-loading"
1096
- />
1097
- )}
1098
- </div>
1099
- </div>
1100
-
1101
- {/* Error state */}
1102
- {error && (
1103
- <div className="flex flex-col items-center gap-2 py-4 px-2" role="alert">
1104
- <AlertCircle className="size-5 text-destructive" />
1105
- <p className="text-sm text-destructive">{error}</p>
1106
- <Button
1107
- variant="outline"
1108
- size="sm"
1109
- onClick={() => popoverQuery.refetch()}
1110
- type="button"
1111
- >
1112
- {t('lookup.retry')}
1113
- </Button>
1114
- </div>
1115
- )}
1116
-
1117
- {/* Loading state (initial load only, not search refinement) */}
1118
- {loading && filteredOptions.length === 0 && !error && (
1119
- <div className="flex flex-col items-center gap-2 py-6" role="status" aria-live="polite">
1120
- <Loader2 className="size-6 animate-spin text-muted-foreground" />
1121
- <p className="text-sm text-muted-foreground">{t('lookup.loading')}</p>
1122
- </div>
1123
- )}
1124
-
1125
- {/* Options list */}
1126
- {!error && !(loading && filteredOptions.length === 0) && (
1127
- <div ref={listRef} className="max-h-64 overflow-y-auto px-1 pb-1" role="listbox" id={listboxId}>
1128
- {visibleOptions.length === 0 ? (
1129
- <div className="py-4 text-center">
1130
- <p className="text-sm text-muted-foreground">
1131
- {t('lookup.noOptions')}
1132
- </p>
1133
- {/* Quick-create entry */}
1134
- {canCreate && (
1135
- <Button
1136
- variant="ghost"
1137
- size="sm"
1138
- className="mt-2 gap-1"
1139
- type="button"
1140
- disabled={creating}
1141
- onClick={() => handleCreateNew(searchQuery)}
1142
- >
1143
- {creating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
1144
- {searchQuery ? t('lookup.createNamed', { name: searchQuery }) : t('lookup.createNew')}
1145
- </Button>
1146
- )}
1147
- </div>
1148
- ) : (
1149
- <>
1150
- {visibleOptions.map((option, idx) => {
1151
- const isSelected = multiple
1152
- ? (Array.isArray(value) ? value : []).map(normalizeId).includes(option.value)
1153
- : normalizeId(value) === option.value;
1154
- const isActive = idx === activeIndex;
1155
- const showRecentHeader = recentCount > 0 && idx === 0;
1156
- const showResultsHeader = recentCount > 0 && idx === recentCount;
1157
-
1158
- return (
1159
- <React.Fragment key={option.value}>
1160
- {showRecentHeader && (
1161
- <div className="px-3 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
1162
- {t('lookup.recentlyUsed')}
1163
- </div>
1164
- )}
1165
- {showResultsHeader && (
1166
- <div className="mt-1 border-t px-3 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
1167
- {t('lookup.allResults')}
1168
- </div>
1169
- )}
1170
- <button
1171
- id={`${listboxId}-opt-${idx}`}
1172
- data-lookup-index={idx}
1173
- role="option"
1174
- aria-selected={isSelected}
1175
- title={previewOf(option)}
1176
- onClick={() => handleSelect(option)}
1177
- className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
1178
- isActive
1179
- ? 'bg-accent text-accent-foreground'
1180
- : isSelected
1181
- ? 'bg-accent/50 text-accent-foreground'
1182
- : ''
1183
- }`}
1184
- type="button"
1185
- >
1186
- <div className="min-w-0 flex-1">
1187
- <span className="block truncate">{option.label}</span>
1188
- {option.description && (
1189
- <span className="block truncate text-xs text-muted-foreground">
1190
- {option.description}
1191
- </span>
1192
- )}
1193
- </div>
1194
- {isSelected && (
1195
- <Badge variant="default" className="ml-2 shrink-0">{t('lookup.selectedBadge')}</Badge>
1196
- )}
1197
- </button>
1198
- </React.Fragment>
1199
- );
1200
- })}
1201
- {/* Show total count when fetched from DataSource */}
1202
- {hasDataSource && totalCount > filteredOptions.length && (
1203
- <p className="text-xs text-muted-foreground text-center py-2">
1204
- {t('lookup.showingResults', { shown: filteredOptions.length, total: totalCount })}
1205
- </p>
1206
- )}
1207
- {/* "Show All Results" button — opens the full Record Picker (Level 2) */}
1208
- {hasDataSource && totalCount > filteredOptions.length && (
1209
- <button
1210
- type="button"
1211
- className="w-full text-center px-3 py-2 rounded-md text-sm font-medium text-primary hover:bg-accent flex items-center justify-center gap-1.5"
1212
- onClick={() => {
1213
- setIsOpen(false);
1214
- setIsPickerOpen(true);
1215
- }}
1216
- data-testid="show-all-results"
1217
- >
1218
- <TableProperties className="size-3.5" />
1219
- {t('lookup.showAllResults', { count: totalCount })}
1220
- </button>
1221
- )}
1222
- {/* Quick-create entry (below results) */}
1223
- {canCreate && (
1224
- <button
1225
- type="button"
1226
- className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center gap-1.5 text-muted-foreground"
1227
- disabled={creating}
1228
- onClick={() => handleCreateNew(searchQuery)}
1229
- >
1230
- {creating ? <Loader2 className="size-3.5 animate-spin" /> : <Plus className="size-3.5" />}
1231
- {searchQuery ? t('lookup.createNamed', { name: searchQuery }) : t('lookup.createNew')}
1232
- </button>
1233
- )}
1234
- </>
1235
- )}
1236
- </div>
1237
- )}
1238
- </PopoverContent>
1239
- </Popover>
1240
-
1241
- {/* "Browse All" button — classic lookups only; search fields open the
1242
- PeoplePicker from the trigger itself, so this would be redundant. */}
1243
- {hasDataSource && pickerVariant !== 'search' && (
1244
- <Button
1245
- variant="outline"
1246
- size="icon"
1247
- className="shrink-0"
1248
- type="button"
1249
- // Gated exactly like the main trigger (#2215) — pre-fix this button
1250
- // opened the full unscoped table while the dependency was missing.
1251
- disabled={dependenciesMissing || props.disabled}
1252
- onClick={() => setIsPickerOpen(true)}
1253
- aria-label={t('lookup.browseAll')}
1254
- title={dependenciesMissing
1255
- ? t('lookup.selectFirst', { fields: dependsOnFieldsText })
1256
- : t('lookup.browseAll')}
1257
- data-testid="browse-all-records"
1258
- >
1259
- <TableProperties className="size-4" />
1260
- </Button>
1261
- )}
1262
- </div>
1263
- )}
1264
-
1265
- {/* Level 2: classic table picker — search fields use the inline combobox above. */}
1266
- {hasDataSource && dataSource && referenceTo && pickerVariant !== 'search' && (
1267
- <RecordPickerDialog
1268
- open={isPickerOpen}
1269
- onOpenChange={setIsPickerOpen}
1270
- title={lookupField?.label || t('common.select')}
1271
- multiple={multiple}
1272
- dataSource={dataSource}
1273
- objectName={referenceTo}
1274
- columns={pickerColumns}
1275
- displayField={displayField}
1276
- titleFormat={refTitleFormat}
1277
- idField={idField}
1278
- pageSize={lookupPageSize}
1279
- value={pickerValue}
1280
- onSelect={onChange}
1281
- onSelectRecords={handlePickerSelectRecords}
1282
- lookupFilters={lookupFilters}
1283
- baseFilter={dependentFilter}
1284
- cellRenderer={getCellRendererResolver()}
1285
- fieldsMeta={refObjectSchema?.fields}
1286
- filterColumns={filterColumns}
1287
- />
1288
- )}
1289
- </div>
1290
- );
1291
- }