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