@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,1316 +0,0 @@
1
- import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
2
- import {
3
- Button,
4
- cn,
5
- Dialog,
6
- DialogContent,
7
- DialogHeader,
8
- DialogTitle,
9
- DialogFooter,
10
- Input,
11
- Label,
12
- Select,
13
- SelectContent,
14
- SelectItem,
15
- SelectTrigger,
16
- SelectValue,
17
- Checkbox,
18
- Table,
19
- TableHeader,
20
- TableBody,
21
- TableHead,
22
- TableRow,
23
- TableCell,
24
- Separator,
25
- Skeleton,
26
- } from '@object-ui/components';
27
- import {
28
- Search,
29
- Loader2,
30
- AlertCircle,
31
- ChevronLeft,
32
- ChevronRight,
33
- Check,
34
- ArrowUpDown,
35
- ArrowUp,
36
- ArrowDown,
37
- SlidersHorizontal,
38
- X,
39
- } from 'lucide-react';
40
- import type { DataSource, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
41
- import { useSafeFieldLabel } from '@object-ui/i18n';
42
- import { useFieldTranslation } from './useFieldTranslation';
43
- import { useRecordQuery } from './useRecordQuery';
44
-
45
- /** Default page size for the Record Picker dialog */
46
- const DEFAULT_PAGE_SIZE = 10;
47
-
48
- /** Minimum column width when resizing (px) */
49
- const MIN_COL_WIDTH = 60;
50
-
51
- /** Number of skeleton rows displayed during initial loading */
52
- const SKELETON_ROW_COUNT = 5;
53
-
54
- /**
55
- * Cell renderer function signature — matches getCellRenderer from @object-ui/fields.
56
- * Accepts a field type and returns a React component that renders a formatted cell.
57
- */
58
- export type CellRendererResolver = (fieldType: string) => React.FC<{ value: any; field: any }>;
59
-
60
- /**
61
- * Filter column definition used by the inline filter bar.
62
- * A subset of LookupColumnDef enriched with filter-specific metadata.
63
- * Compatible with FilterUISchema.filters entries for easy bridging.
64
- */
65
- export interface RecordPickerFilterColumn {
66
- field: string;
67
- label?: string;
68
- type: 'text' | 'number' | 'select' | 'date' | 'boolean';
69
- options?: Array<{ label: string; value: any }>;
70
- }
71
-
72
- /**
73
- * Props passed to the custom filter bar renderer (renderFilterBar slot).
74
- * Allows plugging in FilterUI or any custom component.
75
- */
76
- export interface RecordPickerFilterBarProps {
77
- /** Filter column definitions describing each filterable field */
78
- filterColumns: RecordPickerFilterColumn[];
79
- /** Current filter values keyed by field name */
80
- values: Record<string, any>;
81
- /** Called when a single filter value changes */
82
- onChange: (field: string, value: any) => void;
83
- /** Clear all filter values */
84
- onClear: () => void;
85
- /** Number of actively applied filters */
86
- activeCount: number;
87
- }
88
-
89
- /**
90
- * Props passed to the custom grid renderer (renderGrid slot).
91
- * Allows plugging in ObjectGrid or any compatible table component.
92
- */
93
- export interface RecordPickerGridSlotProps {
94
- /** Resolved column definitions */
95
- columns: LookupColumnDef[];
96
- /** Current page of records */
97
- records: any[];
98
- /** Whether data is loading */
99
- loading: boolean;
100
- /** Total record count across all pages */
101
- totalCount: number;
102
- /** Current page number (1-based) */
103
- currentPage: number;
104
- /** Records per page */
105
- pageSize: number;
106
- /** Current sort field, null if unsorted */
107
- sortField: string | null;
108
- /** Current sort direction */
109
- sortDirection: 'asc' | 'desc';
110
- /** Called when a column header is clicked to sort */
111
- onSort: (field: string) => void;
112
- /** Called when page changes */
113
- onPageChange: (page: number) => void;
114
- /** Called when a row is clicked */
115
- onRowClick: (record: any) => void;
116
- /** Check if a record is selected */
117
- isSelected: (record: any) => boolean;
118
- /** Whether multiple selection is enabled */
119
- multiple: boolean;
120
- /** Record ID field name */
121
- idField: string;
122
- /** Cell renderer resolver */
123
- cellRenderer?: CellRendererResolver;
124
- }
125
-
126
- /**
127
- * Normalise a lookup_columns entry (string | LookupColumnDef) into a
128
- * concrete LookupColumnDef object.
129
- */
130
- function normalizeColumn(col: string | LookupColumnDef): LookupColumnDef {
131
- return typeof col === 'string' ? { field: col } : col;
132
- }
133
-
134
- /**
135
- * Pretty-print a field name as a column header label.
136
- * Converts snake_case / camelCase to Title Case.
137
- */
138
- function fieldToLabel(field: string): string {
139
- return field
140
- .replace(/_/g, ' ')
141
- .replace(/([a-z])([A-Z])/g, '$1 $2')
142
- .replace(/\b\w/g, c => c.toUpperCase());
143
- }
144
-
145
- /**
146
- * Convert LookupFilterDef[] to a Record<string, any> compatible with
147
- * QueryParams.$filter. Supports operator mapping for eq/ne/gt/lt/gte/lte/
148
- * contains/in/notIn.
149
- */
150
- export function lookupFiltersToRecord(
151
- filters: LookupFilterDef[],
152
- ): Record<string, any> {
153
- const result: Record<string, any> = {};
154
- for (const f of filters) {
155
- switch (f.operator) {
156
- case 'eq':
157
- result[f.field] = f.value;
158
- break;
159
- case 'ne':
160
- result[f.field] = { $ne: f.value };
161
- break;
162
- case 'gt':
163
- result[f.field] = { $gt: f.value };
164
- break;
165
- case 'lt':
166
- result[f.field] = { $lt: f.value };
167
- break;
168
- case 'gte':
169
- result[f.field] = { $gte: f.value };
170
- break;
171
- case 'lte':
172
- result[f.field] = { $lte: f.value };
173
- break;
174
- case 'contains':
175
- result[f.field] = { $contains: f.value };
176
- break;
177
- case 'in':
178
- result[f.field] = { $in: f.value };
179
- break;
180
- case 'notIn':
181
- result[f.field] = { $nin: f.value };
182
- break;
183
- }
184
- }
185
- return result;
186
- }
187
-
188
- /**
189
- * A select option as the object schema declares it — `{ value, label }` plus
190
- * whatever the renderer also reads (`color`, …), which the i18n translation
191
- * carries through untouched.
192
- */
193
- type SchemaOption = { value: any; label: string; [key: string]: any };
194
-
195
- /**
196
- * Resolve a field's select options from the referenced object's schema field
197
- * definition (`fieldsMeta[field]`), translated through the shared i18n option
198
- * path.
199
- *
200
- * This is the SINGLE source for both surfaces that need option labels: the
201
- * table cells (#3333) and the filter panel's select inputs (#3336) — so a
202
- * picker column and the filter input for that same column can never disagree
203
- * about what an option is called.
204
- *
205
- * Returns `undefined` when the schema field declares no options: a select
206
- * field with no authored options genuinely has nothing to offer, and
207
- * synthesising entries from the loaded page's raw stored values would paper
208
- * over the metadata gap with a list that changes per page.
209
- */
210
- function resolveSchemaOptions(
211
- meta: { options?: unknown } | undefined,
212
- objectName: string,
213
- fieldName: string,
214
- translateOptions: (o: string, f: string, opts: SchemaOption[]) => SchemaOption[],
215
- ): SchemaOption[] | undefined {
216
- const raw = meta?.options;
217
- if (!Array.isArray(raw) || raw.length === 0) return undefined;
218
- const options = raw as SchemaOption[];
219
- if (!objectName) return options;
220
- return translateOptions(objectName, fieldName, options);
221
- }
222
-
223
- /**
224
- * Option-value round-trip helpers for the filter panel's `select` input
225
- * (#3422).
226
- *
227
- * Radix `Select` speaks strings only: an option renders as
228
- * `value={String(opt.value)}` and `onValueChange` hands that same string back.
229
- * A filter option's value, however, is whatever the metadata author wrote —
230
- * `lookup_filters: [{ field: 'level', operator: 'in', value: [1, 2, 3] }]`
231
- * derives options with NUMBER values (`LookupFilterDef.value` is `unknown`) —
232
- * so writing the control's string straight into `$filter` queried
233
- * `{ level: "1" }` against records storing `level: 1`, and the panel returned
234
- * nothing for an option that plainly has records.
235
- *
236
- * The remap therefore happens at the CONTROL boundary, not in
237
- * `filterValuesToRecord`: the control speaks string, the payload keeps the
238
- * authored type. Coercing downstream would mean guessing whether `"1"` meant
239
- * `1` or `"1"` — a guess the option list already answers exactly.
240
- *
241
- * Replicated from `matchOptionValue` / `toControlValue` in
242
- * `packages/components/src/renderers/form/option-value.ts` (#3090), which
243
- * solved the identical morph for the standalone form's select. Those two are
244
- * module-private to `@object-ui/components` (its public barrel does not export
245
- * them and the package publishes no deep subpath), so `@object-ui/fields`
246
- * keeps its own copy of the four lines rather than widening another package's
247
- * API to share them.
248
- */
249
-
250
- /**
251
- * Stringify a value for a string-speaking control, preserving null/undefined
252
- * (an absent value must stay absent, not become `"undefined"`).
253
- */
254
- function toControlValue(value: unknown): string | undefined {
255
- return value == null ? undefined : String(value);
256
- }
257
-
258
- /**
259
- * Map a control's string back to the authored option value, so a numeric /
260
- * boolean / object option round-trips with its type intact. Falls back to the
261
- * raw string when nothing matches (a stale value, or a column that declares no
262
- * options) — the pre-#3422 behaviour, so unmatched paths change nothing.
263
- *
264
- * When two options share a `String()` form — `1` and `'1'`, or two object
265
- * values that both print `[object Object]` — the FIRST match wins. Such a list
266
- * is already unrenderable as a dropdown (Radix would receive two `SelectItem`s
267
- * with the same `value`, and React two children with the same key), so this
268
- * tie-break exists to be deterministic, not to make an ambiguous option list
269
- * work.
270
- */
271
- function matchOptionValue(
272
- options: ReadonlyArray<{ value: unknown }> | undefined,
273
- raw: string,
274
- ): unknown {
275
- const hit = options?.find(o => String(o.value) === raw);
276
- return hit ? hit.value : raw;
277
- }
278
-
279
- /**
280
- * Convert user-entered filter bar values into a $filter Record.
281
- * Each key is a field name, each value the user-entered value.
282
- * Empty/null values are ignored.
283
- *
284
- * Note the empty string is the "no filter on this field" sentinel here (and
285
- * the `select` control's "nothing picked" value), so an option whose authored
286
- * value is `''` cannot be expressed as an active filter. That predates #3422
287
- * and is unchanged by it — the round-trip above restores an option's TYPE, it
288
- * does not redefine what counts as an empty selection.
289
- */
290
- function filterValuesToRecord(
291
- values: Record<string, any>,
292
- filterColumns: RecordPickerFilterColumn[],
293
- ): Record<string, any> {
294
- const result: Record<string, any> = {};
295
- for (const col of filterColumns) {
296
- const v = values[col.field];
297
- if (v === undefined || v === null || v === '') continue;
298
- if (col.type === 'boolean') {
299
- result[col.field] = Boolean(v);
300
- } else if (col.type === 'text') {
301
- result[col.field] = { $contains: v };
302
- } else {
303
- result[col.field] = v;
304
- }
305
- }
306
- return result;
307
- }
308
-
309
- export interface RecordPickerDialogProps {
310
- /** Whether the dialog is open */
311
- open: boolean;
312
- /** Called when the dialog should close */
313
- onOpenChange: (open: boolean) => void;
314
-
315
- /** Dialog title */
316
- title?: string;
317
- /** Allow multiple selection */
318
- multiple?: boolean;
319
-
320
- /** DataSource to fetch records from */
321
- dataSource: DataSource;
322
- /** Object name to query (e.g. 'customers') */
323
- objectName: string;
324
-
325
- /** Columns to display. Defaults to [displayField, descriptionField]. */
326
- columns?: Array<string | LookupColumnDef>;
327
- /** Primary display field (default: 'name') */
328
- displayField?: string;
329
- /**
330
- * Optional `titleFormat` template (e.g. `"{full_name}"` or
331
- * `"{case_number} - {subject}"`). When set and the displayField column is
332
- * auto-inferred, the column renders via the template instead of reading
333
- * a possibly-missing field. Mirrors how DetailView/ObjectCalendar resolve
334
- * record titles.
335
- */
336
- titleFormat?: string | null;
337
- /** Record id field (default: 'id') */
338
- idField?: string;
339
-
340
- /** Page size (default: 10) */
341
- pageSize?: number;
342
-
343
- /** Currently selected value(s) */
344
- value?: any;
345
- /** Called when selection changes */
346
- onSelect: (value: any) => void;
347
-
348
- /**
349
- * Called with the full record objects corresponding to the selected value(s).
350
- * Useful for parent components (e.g. LookupField) that need display data
351
- * (labels, descriptions) for the selected records beyond just their IDs.
352
- */
353
- onSelectRecords?: (records: any[]) => void;
354
-
355
- /**
356
- * Base filters applied to every query.
357
- * Converted from LookupFieldMetadata.lookup_filters.
358
- * Restricts which records are selectable (e.g. only active records).
359
- */
360
- lookupFilters?: LookupFilterDef[];
361
-
362
- /**
363
- * Hard filter constraints applied to every query, in QueryParams.$filter
364
- * record form. Unlike `lookupFilters`, entries here never surface in the
365
- * filter bar and cannot be overridden by user filter input — used for the
366
- * dependent (cascading) lookup chain, where the parent field's value MUST
367
- * scope the candidate set (#2215).
368
- */
369
- baseFilter?: Record<string, any>;
370
-
371
- /**
372
- * Cell renderer resolver function.
373
- * When provided, columns with a `type` property will be rendered using the
374
- * resolved cell renderer (e.g. badges for select, formatted currency, etc.).
375
- * Typically pass `getCellRenderer` from @object-ui/fields.
376
- */
377
- cellRenderer?: CellRendererResolver;
378
-
379
- /**
380
- * The referenced object's schema `fields` map (field name → field
381
- * definition). When provided, cell renderers receive the FULL field
382
- * metadata — `options`, `currency`, `scale`, `precision`, `format`,
383
- * `reference_to`, … — exactly like the list view enriches its columns from
384
- * the object schema. Without it a `select` column falls back to
385
- * title-casing the raw stored value instead of resolving the option label
386
- * (#3333: `manufacturing` rendered as "Manufacturing" instead of the
387
- * authored option label).
388
- *
389
- * The filter bar reads the same map: a `select` filter column with no
390
- * authored `options` takes them from the schema field here, so the filter
391
- * panel's dropdown offers exactly the options the table cells render
392
- * (#3336 — it used to open empty, leaving the field unfilterable).
393
- */
394
- fieldsMeta?: Record<string, any>;
395
-
396
- /**
397
- * Filter bar column definitions.
398
- * When provided, shows an inline filter bar below the search input.
399
- * Columns can include type-specific inputs (text, number, select, date, boolean).
400
- */
401
- filterColumns?: RecordPickerFilterColumn[];
402
-
403
- /**
404
- * Custom filter bar renderer slot.
405
- * When provided, replaces the built-in filter bar with a custom component
406
- * (e.g. FilterUI from @object-ui/plugin-view).
407
- * Receives filter state and callbacks via RecordPickerFilterBarProps.
408
- *
409
- * @example
410
- * renderFilterBar={(props) => (
411
- * <FilterUI
412
- * schema={{ type: 'filter-ui', filters: props.filterColumns, layout: 'inline' }}
413
- * onChange={(values) => Object.entries(values).forEach(([k, v]) => props.onChange(k, v))}
414
- * />
415
- * )}
416
- */
417
- renderFilterBar?: (props: RecordPickerFilterBarProps) => React.ReactNode;
418
-
419
- /**
420
- * Custom grid renderer slot.
421
- * When provided, replaces the built-in table with a custom grid component
422
- * (e.g. ObjectGrid from @object-ui/plugin-grid).
423
- * Receives data, columns, and interaction callbacks via RecordPickerGridSlotProps.
424
- *
425
- * @example
426
- * renderGrid={(props) => (
427
- * <ObjectGrid
428
- * schema={{ type: 'object-grid', objectName, columns: props.columns }}
429
- * dataSource={dataSource}
430
- * />
431
- * )}
432
- */
433
- renderGrid?: (props: RecordPickerGridSlotProps) => React.ReactNode;
434
- }
435
-
436
- /**
437
- * RecordPickerDialog — Enterprise-grade record selection dialog.
438
- *
439
- * Renders records in a table with multi-column display, search,
440
- * pagination, column sorting, keyboard navigation, loading/error/empty
441
- * states, and single/multi-select. Responsive: mobile-friendly width
442
- * via Tailwind breakpoints.
443
- */
444
- export function RecordPickerDialog({
445
- open,
446
- onOpenChange,
447
- title = 'Select Record',
448
- multiple = false,
449
- dataSource,
450
- objectName,
451
- columns: columnsProp,
452
- displayField = 'name',
453
- titleFormat,
454
- idField = 'id',
455
- pageSize = DEFAULT_PAGE_SIZE,
456
- value,
457
- onSelect,
458
- onSelectRecords,
459
- lookupFilters,
460
- baseFilter,
461
- cellRenderer,
462
- fieldsMeta,
463
- filterColumns,
464
- renderFilterBar,
465
- renderGrid,
466
- }: RecordPickerDialogProps) {
467
- const { t } = useFieldTranslation();
468
- const { translateOptions } = useSafeFieldLabel();
469
-
470
- // Query state (records/loading/error/total + page/search/sort) lives in the
471
- // shared useRecordQuery kernel — instantiated after mergedFilter below.
472
-
473
- // For multi-select, track pending selections before confirming
474
- const [pendingSelection, setPendingSelection] = useState<Set<any>>(new Set());
475
-
476
- // Cache selected record objects across page navigations (multi-select).
477
- // When the user toggles a row the full record is stored here so that
478
- // onSelectRecords can return complete objects even after paging away.
479
- const selectedRecordsMap = useRef<Map<any, any>>(new Map());
480
-
481
- // Keyboard navigation: focused row index
482
- const [focusedRow, setFocusedRow] = useState(-1);
483
- const tableBodyRef = useRef<HTMLTableSectionElement>(null);
484
-
485
- // Filter bar state
486
- const [filterBarOpen, setFilterBarOpen] = useState(false);
487
- const [filterValues, setFilterValues] = useState<Record<string, any>>({});
488
-
489
- // Column resize state: widths keyed by field name
490
- const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
491
- const resizeRef = useRef<{ field: string; startX: number; startWidth: number } | null>(null);
492
-
493
- // Page jump input state
494
- const [pageJumpValue, setPageJumpValue] = useState('');
495
-
496
- // Resolved columns
497
- const resolvedColumns = useMemo<LookupColumnDef[]>(() => {
498
- if (columnsProp && columnsProp.length > 0) {
499
- return columnsProp.map(normalizeColumn);
500
- }
501
- // Auto-infer: just use displayField
502
- return [{ field: displayField, label: fieldToLabel(displayField) }];
503
- }, [columnsProp, displayField]);
504
-
505
- // Field descriptors handed to the type-aware cell renderers, enriched from
506
- // the referenced object's schema (`fieldsMeta`) the same way the list view
507
- // enriches its columns. This is what lets a `select` column resolve its
508
- // option label (options + i18n) instead of title-casing the raw value
509
- // (#3333). Columns whose def carries no `type` inherit the schema field's
510
- // type so authored string `lookup_columns` format identically.
511
- const columnFieldDescriptors = useMemo<Record<string, any>>(() => {
512
- const map: Record<string, any> = {};
513
- for (const col of resolvedColumns) {
514
- const meta = fieldsMeta?.[col.field];
515
- const type = col.type ?? meta?.type;
516
- if (!type) continue;
517
- const descriptor: any = meta
518
- ? { ...meta, name: col.field, type }
519
- : { name: col.field, type };
520
- const options = resolveSchemaOptions(meta, objectName, col.field, translateOptions);
521
- if (options) descriptor.options = options;
522
- map[col.field] = descriptor;
523
- }
524
- return map;
525
- }, [resolvedColumns, fieldsMeta, objectName, translateOptions]);
526
-
527
- // Auto-generate filter columns from lookupFilters when no explicit filterColumns given.
528
- // Each LookupFilterDef becomes a filterable field with inferred type.
529
- const baseFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
530
- if (filterColumns && filterColumns.length > 0) return filterColumns;
531
- // Auto-derive from lookupFilters: each filter entry becomes a filterable field
532
- if (lookupFilters && lookupFilters.length > 0) {
533
- return lookupFilters.map(f => {
534
- // Infer filter input type from value type first, then fall back to operator
535
- let type: RecordPickerFilterColumn['type'] = 'text';
536
- if (typeof f.value === 'boolean') {
537
- type = 'boolean';
538
- } else if (Array.isArray(f.value)) {
539
- type = 'select';
540
- } else if (typeof f.value === 'number') {
541
- type = 'number';
542
- } else if (f.operator === 'gt' || f.operator === 'lt' || f.operator === 'gte' || f.operator === 'lte') {
543
- type = 'number';
544
- } else if (f.operator === 'in' || f.operator === 'notIn') {
545
- type = 'select';
546
- }
547
- return {
548
- field: f.field,
549
- label: fieldToLabel(f.field),
550
- type,
551
- // For array values (in/notIn), derive selectable options
552
- ...(Array.isArray(f.value) ? {
553
- options: (f.value as any[]).map(v => {
554
- if (v != null && typeof v === 'object') {
555
- const obj = v as Record<string, unknown>;
556
- return { label: String(obj.name || obj.label || obj.title || v), value: v };
557
- }
558
- return { label: String(v), value: v };
559
- }),
560
- } : {}),
561
- };
562
- });
563
- }
564
- return undefined;
565
- }, [filterColumns, lookupFilters]);
566
-
567
- // Filter columns as the filter bar consumes them: every `select` filter
568
- // carries the options its schema field declares. The filter panel's Select reads
569
- // `col.options` — a derived select column (LookupField turns each typed picker
570
- // column into a filter column) carries none, so the dropdown opened empty and
571
- // the field could not be filtered at all (#3336).
572
- //
573
- // The options come from `fieldsMeta` through `resolveSchemaOptions`, i.e. the
574
- // SAME schema source + i18n translation the table cells use (#3333) — not a
575
- // second derivation that could drift from the cells. An explicitly authored
576
- // `options` list on the filter column always wins (it is the more specific
577
- // statement), and a schema field with no options stays optionless: an empty
578
- // dropdown for THAT field is the honest rendering of missing metadata.
579
- const effectiveFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
580
- if (!baseFilterColumns) return undefined;
581
- return baseFilterColumns.map(col => {
582
- if (col.type !== 'select') return col;
583
- if (col.options && col.options.length > 0) return col;
584
- const options = resolveSchemaOptions(fieldsMeta?.[col.field], objectName, col.field, translateOptions);
585
- return options ? { ...col, options } : col;
586
- });
587
- }, [baseFilterColumns, fieldsMeta, objectName, translateOptions]);
588
-
589
- // Merge base lookup_filters with user filter bar values. The hard
590
- // `baseFilter` constraint (dependent-lookup chain, #2215) is spread LAST so
591
- // user filter-bar input can never widen it back out.
592
- const mergedFilter = useMemo<Record<string, any> | undefined>(() => {
593
- const lookupBase = lookupFilters?.length
594
- ? lookupFiltersToRecord(lookupFilters)
595
- : {};
596
- const userFilter = effectiveFilterColumns?.length
597
- ? filterValuesToRecord(filterValues, effectiveFilterColumns)
598
- : {};
599
- const combined = { ...lookupBase, ...userFilter, ...(baseFilter ?? {}) };
600
- return Object.keys(combined).length > 0 ? combined : undefined;
601
- }, [lookupFilters, effectiveFilterColumns, filterValues, baseFilter]);
602
-
603
- // Shared query kernel: builds params, fetches, and owns records/loading/error/
604
- // total plus the page/search/sort controls. Selection state stays local (above).
605
- const query = useRecordQuery({
606
- dataSource,
607
- objectName,
608
- enabled: open,
609
- pageSize,
610
- paginate: true,
611
- filter: mergedFilter,
612
- });
613
- // Preserve the previous local names so the handlers and render below are
614
- // unchanged (the migration is a pure refactor).
615
- const { records, loading, error } = query;
616
- const totalCount = query.total;
617
- const totalPages = query.totalPages;
618
- const searchQuery = query.search;
619
- const currentPage = query.page;
620
- const sortField = query.sort?.field ?? null;
621
- const sortDirection: 'asc' | 'desc' = query.sort?.direction ?? 'asc';
622
- const setCurrentPage = query.setPage;
623
- const handleSort = query.toggleSort;
624
- const handleSearchChange = query.setSearch;
625
-
626
- // Reset non-query UI/selection state when the dialog closes. Query state
627
- // (search/page/sort/records) is cleared by useRecordQuery when `enabled`
628
- // (== open) goes false, kept separate so resets never cascade into a fetch
629
- // (React #185).
630
- useEffect(() => {
631
- if (!open) {
632
- setFocusedRow(-1);
633
- setFilterBarOpen(false);
634
- setFilterValues({});
635
- setColumnWidths({});
636
- setPageJumpValue('');
637
- setPendingSelection(new Set(
638
- multiple ? (Array.isArray(value) ? value : []) : [],
639
- ));
640
- selectedRecordsMap.current.clear();
641
- }
642
- // Intentionally depends only on `open` — `multiple` and `value` are
643
- // captured at close-time and don't need to trigger resets while closed.
644
- // eslint-disable-next-line react-hooks/exhaustive-deps
645
- }, [open]);
646
-
647
- // Initialize pending selection when dialog opens
648
- useEffect(() => {
649
- if (open && multiple) {
650
- setPendingSelection(new Set(Array.isArray(value) ? value : []));
651
- }
652
- }, [open, multiple, value]);
653
-
654
- // Reset keyboard focus whenever the result set changes (previously done
655
- // inline at the end of fetchRecords).
656
- useEffect(() => {
657
- setFocusedRow(-1);
658
- }, [records]);
659
-
660
- // Get record id
661
- const getRecordId = useCallback(
662
- (record: any) => record[idField] ?? record.id ?? record._id,
663
- [idField],
664
- );
665
-
666
- // Check if a record is selected
667
- const isSelected = useCallback(
668
- (record: any) => {
669
- const rid = getRecordId(record);
670
- if (multiple) {
671
- return pendingSelection.has(rid);
672
- }
673
- return value === rid;
674
- },
675
- [multiple, value, pendingSelection, getRecordId],
676
- );
677
-
678
- // Handle row click
679
- const handleRowClick = useCallback(
680
- (record: any) => {
681
- const rid = getRecordId(record);
682
-
683
- if (multiple) {
684
- setPendingSelection(prev => {
685
- const next = new Set(prev);
686
- if (next.has(rid)) {
687
- next.delete(rid);
688
- selectedRecordsMap.current.delete(rid);
689
- } else {
690
- next.add(rid);
691
- selectedRecordsMap.current.set(rid, record);
692
- }
693
- return next;
694
- });
695
- } else {
696
- // Single select — immediately close
697
- onSelect(rid);
698
- onSelectRecords?.([record]);
699
- onOpenChange(false);
700
- }
701
- },
702
- [multiple, getRecordId, onSelect, onSelectRecords, onOpenChange],
703
- );
704
-
705
- // Confirm multi-select
706
- const handleConfirm = useCallback(() => {
707
- const ids = Array.from(pendingSelection);
708
- onSelect(ids);
709
- // Build the full record array from the cache for the caller
710
- const selectedRecords = ids
711
- .map(id => selectedRecordsMap.current.get(id))
712
- .filter(Boolean);
713
- onSelectRecords?.(selectedRecords);
714
- onOpenChange(false);
715
- }, [pendingSelection, onSelect, onSelectRecords, onOpenChange]);
716
-
717
- // Page navigation
718
- const handlePrevPage = useCallback(() => {
719
- setCurrentPage(Math.max(1, currentPage - 1));
720
- }, [setCurrentPage, currentPage]);
721
-
722
- const handleNextPage = useCallback(() => {
723
- setCurrentPage(Math.min(totalPages, currentPage + 1));
724
- }, [setCurrentPage, currentPage, totalPages]);
725
-
726
- // Page jump handler
727
- const handlePageJump = useCallback(
728
- (e: React.KeyboardEvent<HTMLInputElement>) => {
729
- if (e.key !== 'Enter') return;
730
- const page = parseInt(pageJumpValue, 10);
731
- if (!isNaN(page) && page >= 1 && page <= totalPages) {
732
- setCurrentPage(page);
733
- }
734
- setPageJumpValue('');
735
- },
736
- [pageJumpValue, totalPages],
737
- );
738
-
739
- // Keyboard navigation for the table
740
- const handleTableKeyDown = useCallback(
741
- (e: React.KeyboardEvent) => {
742
- if (records.length === 0) return;
743
-
744
- if (e.key === 'ArrowDown') {
745
- e.preventDefault();
746
- setFocusedRow(prev => Math.min(prev + 1, records.length - 1));
747
- } else if (e.key === 'ArrowUp') {
748
- e.preventDefault();
749
- setFocusedRow(prev => Math.max(prev - 1, 0));
750
- } else if (e.key === 'Enter' || e.key === ' ') {
751
- e.preventDefault();
752
- if (focusedRow >= 0 && focusedRow < records.length) {
753
- handleRowClick(records[focusedRow]);
754
- }
755
- }
756
- },
757
- [records, focusedRow, handleRowClick],
758
- );
759
-
760
- // Scroll focused row into view
761
- useEffect(() => {
762
- if (focusedRow >= 0 && tableBodyRef.current) {
763
- const row = tableBodyRef.current.querySelector(`[data-row-index="${focusedRow}"]`);
764
- if (row && typeof row.scrollIntoView === 'function') {
765
- row.scrollIntoView({ block: 'nearest' });
766
- }
767
- }
768
- }, [focusedRow]);
769
-
770
- // Get display value for a cell — type-aware rendering when cellRenderer is provided
771
- const renderCellContent = useCallback((record: any, col: LookupColumnDef): React.ReactNode => {
772
- // When the column is the auto-inferred displayField column and the
773
- // referenced object declares a `titleFormat`, render via the template so
774
- // users see human-readable names instead of a raw id when the displayField
775
- // (commonly defaulted to `name`) doesn't exist on the record.
776
- if (titleFormat && col.field === displayField) {
777
- const EMPTY = '\u0000';
778
- const SEP = '[-\\u2013\\u2014|/·,:]';
779
- let any = false;
780
- const raw = titleFormat.replace(/\{([^{}]+)\}/g, (_m, key) => {
781
- const v = (record as any)[key.trim()];
782
- if (v !== null && v !== undefined && v !== '') {
783
- any = true;
784
- return String(v);
785
- }
786
- return EMPTY;
787
- });
788
- if (any) {
789
- const out = raw
790
- .replace(new RegExp(`\\s*${SEP}\\s*${EMPTY}`, 'g'), '')
791
- .replace(new RegExp(`${EMPTY}\\s*${SEP}\\s*`, 'g'), '')
792
- .replace(new RegExp(EMPTY, 'g'), '')
793
- .replace(/\s+/g, ' ')
794
- .trim();
795
- if (out) return out;
796
- }
797
- }
798
-
799
- const val = record[col.field];
800
-
801
- // Use type-aware renderer when a field descriptor (column `type`, or the
802
- // schema field's type via `fieldsMeta`) and a resolver are available.
803
- const descriptor = columnFieldDescriptors[col.field];
804
- if (descriptor && cellRenderer) {
805
- const Renderer = cellRenderer(descriptor.type);
806
- if (Renderer) {
807
- return <Renderer value={val} field={descriptor} />;
808
- }
809
- }
810
-
811
- // Fallback: plain text formatting
812
- if (val === null || val === undefined) return '';
813
- if (typeof val === 'object') {
814
- // Handle MongoDB types / expanded references
815
- if (val.$numberDecimal) return String(Number(val.$numberDecimal));
816
- if (val.$oid) return String(val.$oid);
817
- if (val.$date) return new Date(val.$date).toLocaleDateString();
818
- if (val.name || val.label) return String(val.name || val.label);
819
- return JSON.stringify(val);
820
- }
821
- if (typeof val === 'boolean') return val ? 'Yes' : 'No';
822
- return String(val);
823
- }, [cellRenderer, titleFormat, displayField, columnFieldDescriptors]);
824
-
825
- // Render sort indicator for a column
826
- const renderSortIcon = useCallback((field: string) => {
827
- if (sortField !== field) {
828
- return <ArrowUpDown className="ml-1 size-3 opacity-40" />;
829
- }
830
- return sortDirection === 'asc'
831
- ? <ArrowUp className="ml-1 size-3" />
832
- : <ArrowDown className="ml-1 size-3" />;
833
- }, [sortField, sortDirection]);
834
-
835
- // Column resize: mouse-down on drag handle
836
- const handleResizeStart = useCallback(
837
- (e: React.MouseEvent, field: string, currentWidth: number) => {
838
- e.preventDefault();
839
- e.stopPropagation();
840
- resizeRef.current = { field, startX: e.clientX, startWidth: currentWidth };
841
-
842
- const handleMouseMove = (moveEvt: MouseEvent) => {
843
- if (!resizeRef.current) return;
844
- const delta = moveEvt.clientX - resizeRef.current.startX;
845
- const newWidth = Math.max(MIN_COL_WIDTH, resizeRef.current.startWidth + delta);
846
- setColumnWidths(prev => ({ ...prev, [resizeRef.current!.field]: newWidth }));
847
- };
848
-
849
- const handleMouseUp = () => {
850
- resizeRef.current = null;
851
- document.removeEventListener('mousemove', handleMouseMove);
852
- document.removeEventListener('mouseup', handleMouseUp);
853
- };
854
-
855
- document.addEventListener('mousemove', handleMouseMove);
856
- document.addEventListener('mouseup', handleMouseUp);
857
- },
858
- [],
859
- );
860
-
861
- // Filter bar: update a single field value
862
- const handleFilterChange = useCallback(
863
- (field: string, val: any) => {
864
- setFilterValues(prev => ({ ...prev, [field]: val }));
865
- setCurrentPage(1);
866
- },
867
- [],
868
- );
869
-
870
- // Filter bar: clear all filter values
871
- const handleFilterClear = useCallback(() => {
872
- setFilterValues({});
873
- setCurrentPage(1);
874
- }, []);
875
-
876
- // Active filter count for badge
877
- const activeFilterCount = useMemo(
878
- () => Object.values(filterValues).filter(v => v !== undefined && v !== null && v !== '').length,
879
- [filterValues],
880
- );
881
-
882
- // Render a single filter bar input
883
- const renderFilterInput = useCallback(
884
- (col: RecordPickerFilterColumn) => {
885
- const val = filterValues[col.field];
886
- const label = col.label || fieldToLabel(col.field);
887
-
888
- switch (col.type) {
889
- case 'select':
890
- return (
891
- <div className="space-y-1">
892
- <Label className="text-xs text-muted-foreground">{label}</Label>
893
- {/* The control speaks string; the stored filter value keeps the
894
- authored option type (#3422 — see `matchOptionValue`). */}
895
- <Select
896
- value={toControlValue(val) ?? ''}
897
- onValueChange={v => handleFilterChange(col.field, matchOptionValue(col.options, v))}
898
- >
899
- <SelectTrigger className="h-8 text-xs">
900
- <SelectValue placeholder={t('lookup.filterPlaceholder', { label })} />
901
- </SelectTrigger>
902
- <SelectContent>
903
- {col.options?.map(opt => (
904
- <SelectItem key={String(opt.value)} value={String(opt.value)}>
905
- {opt.label}
906
- </SelectItem>
907
- ))}
908
- </SelectContent>
909
- </Select>
910
- </div>
911
- );
912
- case 'number':
913
- return (
914
- <div className="space-y-1">
915
- <Label className="text-xs text-muted-foreground">{label}</Label>
916
- <Input
917
- type="number"
918
- className="h-8 text-xs"
919
- value={val ?? ''}
920
- placeholder={t('lookup.filterPlaceholder', { label })}
921
- onChange={e => {
922
- const raw = e.target.value;
923
- handleFilterChange(col.field, raw === '' ? '' : Number(raw));
924
- }}
925
- />
926
- </div>
927
- );
928
- case 'date':
929
- return (
930
- <div className="space-y-1">
931
- <Label className="text-xs text-muted-foreground">{label}</Label>
932
- <Input
933
- type="date"
934
- className="h-8 text-xs"
935
- value={val ?? ''}
936
- onChange={e => handleFilterChange(col.field, e.target.value)}
937
- />
938
- </div>
939
- );
940
- case 'boolean':
941
- return (
942
- <div className="space-y-1">
943
- <Label className="text-xs text-muted-foreground">{label}</Label>
944
- <div className="flex items-center gap-2 h-8">
945
- <Checkbox
946
- checked={Boolean(val)}
947
- onCheckedChange={checked => handleFilterChange(col.field, Boolean(checked))}
948
- />
949
- <span className="text-xs text-muted-foreground">{t('lookup.yes')}</span>
950
- </div>
951
- </div>
952
- );
953
- case 'text':
954
- default:
955
- return (
956
- <div className="space-y-1">
957
- <Label className="text-xs text-muted-foreground">{label}</Label>
958
- <Input
959
- className="h-8 text-xs"
960
- value={val ?? ''}
961
- placeholder={t('lookup.filterPlaceholder', { label })}
962
- onChange={e => handleFilterChange(col.field, e.target.value)}
963
- />
964
- </div>
965
- );
966
- }
967
- },
968
- [filterValues, handleFilterChange],
969
- );
970
-
971
- // Row background class logic: selected > odd-striped > default
972
- const getRowBgClass = useCallback((selected: boolean, idx: number) => {
973
- if (selected) return 'bg-primary/5 hover:bg-primary/10';
974
- if (idx % 2 === 1) return 'bg-muted/20 hover:bg-accent/30';
975
- return 'hover:bg-accent/30';
976
- }, []);
977
-
978
- return (
979
- <Dialog open={open} onOpenChange={onOpenChange}>
980
- <DialogContent
981
- className="w-[95vw] sm:max-w-3xl lg:max-w-5xl max-h-[85vh] sm:max-h-[80vh] flex flex-col gap-0"
982
- data-testid="record-picker-dialog"
983
- >
984
- <DialogHeader>
985
- <DialogTitle>
986
- {title}
987
- {multiple && <span className="sr-only"> (multiple selection)</span>}
988
- </DialogTitle>
989
- </DialogHeader>
990
-
991
- {/* Search bar */}
992
- <div className="relative rounded-md border bg-muted/30 mb-3">
993
- <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
994
- <Input
995
- placeholder={t('table.search')}
996
- value={searchQuery}
997
- onChange={(e) => handleSearchChange(e.target.value)}
998
- className="pl-9 border-0 bg-transparent shadow-none focus-visible:ring-0"
999
- data-testid="record-picker-search"
1000
- />
1001
- {loading && (
1002
- <Loader2
1003
- className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
1004
- data-testid="record-picker-loading-indicator"
1005
- />
1006
- )}
1007
- </div>
1008
-
1009
- {/* Filter bar (inline) — supports external FilterUI via renderFilterBar slot */}
1010
- {effectiveFilterColumns && effectiveFilterColumns.length > 0 && (
1011
- <div className="py-2">
1012
- {renderFilterBar ? (
1013
- /* External filter bar (e.g. FilterUI from plugin-view) */
1014
- <div data-testid="record-picker-filter-bar">
1015
- {renderFilterBar({
1016
- filterColumns: effectiveFilterColumns,
1017
- values: filterValues,
1018
- onChange: handleFilterChange,
1019
- onClear: handleFilterClear,
1020
- activeCount: activeFilterCount,
1021
- })}
1022
- </div>
1023
- ) : (
1024
- /* Built-in filter bar (default) */
1025
- <>
1026
- <div className="flex items-center gap-2" data-testid="record-picker-filter-bar">
1027
- <Button
1028
- type="button"
1029
- variant={activeFilterCount > 0 ? 'secondary' : 'outline'}
1030
- size="sm"
1031
- className="gap-1.5 shrink-0"
1032
- onClick={() => setFilterBarOpen(prev => !prev)}
1033
- >
1034
- <SlidersHorizontal className="size-3.5" />
1035
- {t('lookup.filters')}
1036
- {activeFilterCount > 0 && (
1037
- <span className="inline-flex h-5 min-w-[20px] items-center justify-center rounded-full bg-primary/10 px-1 text-xs font-medium text-primary">
1038
- {activeFilterCount}
1039
- </span>
1040
- )}
1041
- </Button>
1042
- {activeFilterCount > 0 && (
1043
- <Button
1044
- type="button"
1045
- variant="ghost"
1046
- size="sm"
1047
- className="gap-1 text-xs"
1048
- onClick={handleFilterClear}
1049
- >
1050
- <X className="size-3" />
1051
- {t('lookup.clear')}
1052
- </Button>
1053
- )}
1054
- </div>
1055
- {filterBarOpen && (
1056
- <div className="mt-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 border rounded-md p-3 bg-muted/30" data-testid="record-picker-filter-panel">
1057
- {effectiveFilterColumns.map(col => (
1058
- <div key={col.field}>{renderFilterInput(col)}</div>
1059
- ))}
1060
- </div>
1061
- )}
1062
- </>
1063
- )}
1064
- </div>
1065
- )}
1066
-
1067
- {/* Error state */}
1068
- {error && (
1069
- <div className="flex flex-col items-center gap-2 py-4" role="alert">
1070
- <AlertCircle className="size-5 text-destructive" />
1071
- <p className="text-sm text-destructive">{error}</p>
1072
- <Button
1073
- variant="outline"
1074
- size="sm"
1075
- onClick={() => query.refetch()}
1076
- type="button"
1077
- >
1078
- {t('lookup.retry')}
1079
- </Button>
1080
- </div>
1081
- )}
1082
-
1083
- {/* Grid area — external ObjectGrid via renderGrid slot, or built-in table */}
1084
- {renderGrid ? (
1085
- /* External grid component (e.g. ObjectGrid from plugin-grid) */
1086
- <div className="flex-1 min-h-0" data-testid="record-picker-grid-slot">
1087
- {renderGrid({
1088
- columns: resolvedColumns,
1089
- records,
1090
- loading,
1091
- totalCount,
1092
- currentPage,
1093
- pageSize,
1094
- sortField,
1095
- sortDirection,
1096
- onSort: handleSort,
1097
- onPageChange: setCurrentPage,
1098
- onRowClick: handleRowClick,
1099
- isSelected,
1100
- multiple,
1101
- idField,
1102
- cellRenderer,
1103
- })}
1104
- </div>
1105
- ) : (
1106
- /* Built-in table (default) */
1107
- <>
1108
- {/* Skeleton loading state (initial) */}
1109
- {loading && records.length === 0 && !error && (
1110
- <div className="flex-1 overflow-hidden min-h-0 border rounded-md" role="status" aria-live="polite" data-testid="record-picker-skeleton">
1111
- <Table>
1112
- <TableHeader>
1113
- <TableRow className="bg-muted/40">
1114
- {multiple && <TableHead className="w-10" />}
1115
- {resolvedColumns.map(col => (
1116
- <TableHead key={col.field}>
1117
- <Skeleton className="h-4 w-20" />
1118
- </TableHead>
1119
- ))}
1120
- </TableRow>
1121
- </TableHeader>
1122
- <TableBody>
1123
- {Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => (
1124
- <TableRow key={i}>
1125
- {multiple && (
1126
- <TableCell className="w-10">
1127
- <Skeleton className="size-4 rounded" />
1128
- </TableCell>
1129
- )}
1130
- {resolvedColumns.map(col => (
1131
- <TableCell key={col.field}>
1132
- <Skeleton className="h-4 w-full" />
1133
- </TableCell>
1134
- ))}
1135
- </TableRow>
1136
- ))}
1137
- </TableBody>
1138
- </Table>
1139
- </div>
1140
- )}
1141
-
1142
- {/* Empty state */}
1143
- {!loading && !error && records.length === 0 && (
1144
- <div className="py-8 text-center">
1145
- <p className="text-sm text-muted-foreground">{t('lookup.noRecords')}</p>
1146
- </div>
1147
- )}
1148
-
1149
- {/* Table */}
1150
- {!error && records.length > 0 && (
1151
- <div
1152
- className="relative flex-1 overflow-auto min-h-0 border rounded-md"
1153
- aria-busy={loading || undefined}
1154
- data-state={loading ? 'loading' : 'idle'}
1155
- tabIndex={0}
1156
- onKeyDown={handleTableKeyDown}
1157
- role="grid"
1158
- aria-label="Records"
1159
- >
1160
- {/* Loading overlay for subsequent fetches (page/sort/filter) */}
1161
- {loading && (
1162
- <div
1163
- className="absolute inset-0 z-10 flex items-center justify-center bg-background/60"
1164
- data-testid="record-picker-loading-overlay"
1165
- >
1166
- <Loader2 className="size-6 animate-spin text-muted-foreground" />
1167
- </div>
1168
- )}
1169
- <Table style={Object.keys(columnWidths).length > 0 ? { tableLayout: 'fixed' } : undefined}>
1170
- <TableHeader className="sticky top-0 z-[5] bg-muted/50 [&_tr]:border-b" data-testid="record-picker-sticky-header">
1171
- <TableRow>
1172
- {multiple && (
1173
- <TableHead className="w-10" />
1174
- )}
1175
- {resolvedColumns.map(col => {
1176
- const w = columnWidths[col.field];
1177
- const styleWidth = w ? { width: `${w}px`, minWidth: `${w}px` } : col.width ? { width: col.width } : undefined;
1178
- return (
1179
- <TableHead
1180
- key={col.field}
1181
- style={styleWidth}
1182
- className="cursor-pointer select-none relative group text-xs font-semibold uppercase tracking-wider"
1183
- onClick={() => handleSort(col.field)}
1184
- aria-sort={sortField === col.field ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
1185
- >
1186
- <span className="inline-flex items-center">
1187
- {col.label || fieldToLabel(col.field)}
1188
- {renderSortIcon(col.field)}
1189
- </span>
1190
- {/* Column resize handle */}
1191
- <span
1192
- role="separator"
1193
- aria-orientation="vertical"
1194
- className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize opacity-0 group-hover:opacity-100 bg-border hover:bg-primary/50 transition-opacity"
1195
- onMouseDown={e => {
1196
- const th = e.currentTarget.parentElement;
1197
- const rect = th?.getBoundingClientRect();
1198
- handleResizeStart(e, col.field, rect?.width ?? 100);
1199
- }}
1200
- onClick={e => e.stopPropagation()}
1201
- data-testid={`resize-handle-${col.field}`}
1202
- />
1203
- </TableHead>
1204
- );
1205
- })}
1206
- </TableRow>
1207
- </TableHeader>
1208
- <TableBody ref={tableBodyRef}>
1209
- {records.map((record, idx) => {
1210
- const rid = getRecordId(record);
1211
- const selected = isSelected(record);
1212
- const focused = idx === focusedRow;
1213
-
1214
- return (
1215
- <TableRow
1216
- key={rid ?? idx}
1217
- data-row-index={idx}
1218
- className={cn(
1219
- 'cursor-pointer transition-colors',
1220
- getRowBgClass(selected, idx),
1221
- focused && 'ring-2 ring-primary ring-inset',
1222
- )}
1223
- onClick={() => handleRowClick(record)}
1224
- data-testid={`record-row-${rid}`}
1225
- aria-selected={selected}
1226
- >
1227
- {multiple && (
1228
- <TableCell className="w-10">
1229
- {selected && <Check className="size-4 text-primary" />}
1230
- </TableCell>
1231
- )}
1232
- {resolvedColumns.map(col => (
1233
- <TableCell key={col.field} className="py-2.5">
1234
- {renderCellContent(record, col)}
1235
- </TableCell>
1236
- ))}
1237
- </TableRow>
1238
- );
1239
- })}
1240
- </TableBody>
1241
- </Table>
1242
- </div>
1243
- )}
1244
-
1245
- {/* Pagination — fixed bottom bar */}
1246
- {!error && totalCount > 0 && (
1247
- <div
1248
- className="flex items-center justify-between text-sm text-muted-foreground border-t pt-3 mt-2 px-1"
1249
- data-testid="record-picker-pagination"
1250
- >
1251
- <span>
1252
- {totalCount === 1 ? t('lookup.recordCountOne') : t('lookup.recordCount', { count: totalCount })}
1253
- {totalPages > 1 && ` · ${t('lookup.pageOf', { current: currentPage, total: totalPages })}`}
1254
- </span>
1255
- {totalPages > 1 && (
1256
- <div className="flex items-center gap-1.5">
1257
- <Button
1258
- variant="outline"
1259
- size="icon"
1260
- className="size-7"
1261
- onClick={handlePrevPage}
1262
- disabled={currentPage <= 1}
1263
- type="button"
1264
- aria-label={t('lookup.prevPage')}
1265
- >
1266
- <ChevronLeft className="size-4" />
1267
- </Button>
1268
- <Input
1269
- className="h-7 w-12 text-center text-xs px-1"
1270
- placeholder={String(currentPage)}
1271
- value={pageJumpValue}
1272
- onChange={e => setPageJumpValue(e.target.value)}
1273
- onKeyDown={handlePageJump}
1274
- aria-label={t('lookup.jumpToPage')}
1275
- data-testid="record-picker-page-jump"
1276
- />
1277
- <Button
1278
- variant="outline"
1279
- size="icon"
1280
- className="size-7"
1281
- onClick={handleNextPage}
1282
- disabled={currentPage >= totalPages}
1283
- type="button"
1284
- aria-label={t('lookup.nextPage')}
1285
- >
1286
- <ChevronRight className="size-4" />
1287
- </Button>
1288
- </div>
1289
- )}
1290
- </div>
1291
- )}
1292
- </>
1293
- )}
1294
-
1295
- {/* Multi-select confirmation */}
1296
- {multiple && (
1297
- <DialogFooter>
1298
- <div className="flex items-center gap-2 w-full justify-between">
1299
- <span className="text-sm text-muted-foreground">
1300
- {t('table.selected', { count: pendingSelection.size })}
1301
- </span>
1302
- <div className="flex gap-2">
1303
- <Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
1304
- {t('common.cancel')}
1305
- </Button>
1306
- <Button type="button" onClick={handleConfirm}>
1307
- {t('common.confirm')}
1308
- </Button>
1309
- </div>
1310
- </div>
1311
- </DialogFooter>
1312
- )}
1313
- </DialogContent>
1314
- </Dialog>
1315
- );
1316
- }