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