@object-ui/fields 3.1.3 → 3.1.4

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 (88) hide show
  1. package/dist/AddressField-B6fPb2Fs.js +3 -0
  2. package/dist/AddressField-Ctsxkj35.cjs +1 -0
  3. package/dist/AutoNumberField-DXIYJfiB.js +3 -0
  4. package/dist/AutoNumberField-f76XU1bR.cjs +1 -0
  5. package/dist/AvatarField-BHpex3Wa.js +3 -0
  6. package/dist/AvatarField-CXOdYavl.cjs +1 -0
  7. package/dist/BooleanField-Bs0urhsx.js +3 -0
  8. package/dist/BooleanField-DeexI_O2.cjs +1 -0
  9. package/dist/CodeField-DDOMH8E8.cjs +1 -0
  10. package/dist/CodeField-tRACqngO.js +3 -0
  11. package/dist/ColorField-Bar6bOJw.js +3 -0
  12. package/dist/ColorField-CM9CxZ9h.cjs +1 -0
  13. package/dist/CurrencyField-CezTX_rA.cjs +1 -0
  14. package/dist/CurrencyField-D4VwXI9k.js +3 -0
  15. package/dist/DateField-DDUllj7w.cjs +1 -0
  16. package/dist/DateField-DjGiK6BH.js +3 -0
  17. package/dist/DateTimeField-DBh-dYdR.js +3 -0
  18. package/dist/DateTimeField-DDqgVN3b.cjs +1 -0
  19. package/dist/EmailField-B-3nY8Pr.cjs +1 -0
  20. package/dist/EmailField-Cp6yJlpH.js +3 -0
  21. package/dist/FileField-C1bcL7tu.cjs +1 -0
  22. package/dist/FileField-DUgt6ai3.js +3 -0
  23. package/dist/FormulaField-BLV1KgkQ.js +3 -0
  24. package/dist/FormulaField-CsbQIZ7S.cjs +1 -0
  25. package/dist/GeolocationField-BbIRH51u.js +3 -0
  26. package/dist/GeolocationField-UFu9VR-x.cjs +1 -0
  27. package/dist/GridField-2m74haE0.cjs +1 -0
  28. package/dist/GridField-O3Jd1NuJ.js +3 -0
  29. package/dist/ImageField-Cna7g6KL.cjs +1 -0
  30. package/dist/ImageField-rc1Nzcvb.js +3 -0
  31. package/dist/LocationField-DpRe9QlH.cjs +1 -0
  32. package/dist/LocationField-QZPPYo0J.js +3 -0
  33. package/dist/LookupField-BX4sK8NG.js +904 -0
  34. package/dist/LookupField-C9rPrHi_.cjs +1 -0
  35. package/dist/MasterDetailField-BXncIT1s.js +3 -0
  36. package/dist/MasterDetailField-TNP2825Z.cjs +1 -0
  37. package/dist/NumberField-DCHTzmDG.js +3 -0
  38. package/dist/NumberField-SqRwbcY8.cjs +1 -0
  39. package/dist/ObjectField-BEUHpn_X.js +3 -0
  40. package/dist/ObjectField-BWZwaFIG.cjs +1 -0
  41. package/dist/PasswordField-BZ_OeQyw.js +3 -0
  42. package/dist/PasswordField-kLp-ZyPm.cjs +1 -0
  43. package/dist/PercentField-B2t2knlc.js +3 -0
  44. package/dist/PercentField-BzhD6tQm.cjs +1 -0
  45. package/dist/PhoneField-CirvuysP.cjs +1 -0
  46. package/dist/PhoneField-CkrCFmcO.js +3 -0
  47. package/dist/QRCodeField-C4OOLCbE.cjs +1 -0
  48. package/dist/QRCodeField-CQoGow8y.js +3 -0
  49. package/dist/RatingField-CZG8SgQ8.cjs +1 -0
  50. package/dist/RatingField-DecDJkou.js +3 -0
  51. package/dist/RichTextField-Bog2i4NN.cjs +1 -0
  52. package/dist/RichTextField-b-8QbNCJ.js +3 -0
  53. package/dist/SelectField-CAa6c0cB.cjs +1 -0
  54. package/dist/SelectField-DMy5IxRc.js +3 -0
  55. package/dist/SignatureField-BvTPE7Dn.js +3 -0
  56. package/dist/SignatureField-jsb-0AYp.cjs +1 -0
  57. package/dist/SliderField-B7WvKFv7.js +3 -0
  58. package/dist/SliderField-DRjEJSxK.cjs +1 -0
  59. package/dist/SummaryField-DA16Edq3.cjs +1 -0
  60. package/dist/SummaryField-DVSVfKmY.js +3 -0
  61. package/dist/TextAreaField--ut87UiB.js +3 -0
  62. package/dist/TextAreaField-Cxp3DFC6.cjs +1 -0
  63. package/dist/TextField-CkEDGbkQ.js +3 -0
  64. package/dist/TextField-aLAzBaLZ.cjs +1 -0
  65. package/dist/TimeField-D0lF0y_I.cjs +1 -0
  66. package/dist/TimeField-DA_S6lQV.js +3 -0
  67. package/dist/UrlField-D0ugmD3f.cjs +1 -0
  68. package/dist/UrlField-DCnXHR1A.js +3 -0
  69. package/dist/UserField-DGlorzyG.cjs +1 -0
  70. package/dist/UserField-V0GmvyGV.js +3 -0
  71. package/dist/VectorField-BSD85ntU.cjs +1 -0
  72. package/dist/VectorField-CVi5yrrc.js +3 -0
  73. package/dist/fields/src/index.d.ts +1 -0
  74. package/dist/fields/src/record-picker-dialog.stories.d.ts +25 -0
  75. package/dist/fields/src/record-picker.test.d.ts +1 -0
  76. package/dist/fields/src/widgets/RecordPickerDialog.d.ts +166 -0
  77. package/dist/fields/src/widgets/_cell-renderer-bridge.d.ts +3 -0
  78. package/dist/index.cjs +4 -9
  79. package/dist/index.js +2229 -2850
  80. package/dist/jsx-runtime-DHCF5ANT.cjs +6 -0
  81. package/dist/jsx-runtime-MxADXuRH.js +189 -0
  82. package/package.json +7 -6
  83. package/src/index.tsx +6 -0
  84. package/src/record-picker-dialog.stories.tsx +160 -0
  85. package/src/record-picker.test.tsx +1696 -0
  86. package/src/widgets/LookupField.tsx +254 -150
  87. package/src/widgets/RecordPickerDialog.tsx +1182 -0
  88. package/src/widgets/_cell-renderer-bridge.ts +19 -0
@@ -0,0 +1,1182 @@
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, QueryParams, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
41
+
42
+ /** Default page size for the Record Picker dialog */
43
+ const DEFAULT_PAGE_SIZE = 10;
44
+
45
+ /** Minimum column width when resizing (px) */
46
+ const MIN_COL_WIDTH = 60;
47
+
48
+ /** Number of skeleton rows displayed during initial loading */
49
+ const SKELETON_ROW_COUNT = 5;
50
+
51
+ /**
52
+ * Cell renderer function signature — matches getCellRenderer from @object-ui/fields.
53
+ * Accepts a field type and returns a React component that renders a formatted cell.
54
+ */
55
+ export type CellRendererResolver = (fieldType: string) => React.FC<{ value: any; field: any }>;
56
+
57
+ /**
58
+ * Filter column definition used by the inline filter bar.
59
+ * A subset of LookupColumnDef enriched with filter-specific metadata.
60
+ * Compatible with FilterUISchema.filters entries for easy bridging.
61
+ */
62
+ export interface RecordPickerFilterColumn {
63
+ field: string;
64
+ label?: string;
65
+ type: 'text' | 'number' | 'select' | 'date' | 'boolean';
66
+ options?: Array<{ label: string; value: any }>;
67
+ }
68
+
69
+ /**
70
+ * Props passed to the custom filter bar renderer (renderFilterBar slot).
71
+ * Allows plugging in FilterUI or any custom component.
72
+ */
73
+ export interface RecordPickerFilterBarProps {
74
+ /** Filter column definitions describing each filterable field */
75
+ filterColumns: RecordPickerFilterColumn[];
76
+ /** Current filter values keyed by field name */
77
+ values: Record<string, any>;
78
+ /** Called when a single filter value changes */
79
+ onChange: (field: string, value: any) => void;
80
+ /** Clear all filter values */
81
+ onClear: () => void;
82
+ /** Number of actively applied filters */
83
+ activeCount: number;
84
+ }
85
+
86
+ /**
87
+ * Props passed to the custom grid renderer (renderGrid slot).
88
+ * Allows plugging in ObjectGrid or any compatible table component.
89
+ */
90
+ export interface RecordPickerGridSlotProps {
91
+ /** Resolved column definitions */
92
+ columns: LookupColumnDef[];
93
+ /** Current page of records */
94
+ records: any[];
95
+ /** Whether data is loading */
96
+ loading: boolean;
97
+ /** Total record count across all pages */
98
+ totalCount: number;
99
+ /** Current page number (1-based) */
100
+ currentPage: number;
101
+ /** Records per page */
102
+ pageSize: number;
103
+ /** Current sort field, null if unsorted */
104
+ sortField: string | null;
105
+ /** Current sort direction */
106
+ sortDirection: 'asc' | 'desc';
107
+ /** Called when a column header is clicked to sort */
108
+ onSort: (field: string) => void;
109
+ /** Called when page changes */
110
+ onPageChange: (page: number) => void;
111
+ /** Called when a row is clicked */
112
+ onRowClick: (record: any) => void;
113
+ /** Check if a record is selected */
114
+ isSelected: (record: any) => boolean;
115
+ /** Whether multiple selection is enabled */
116
+ multiple: boolean;
117
+ /** Record ID field name */
118
+ idField: string;
119
+ /** Cell renderer resolver */
120
+ cellRenderer?: CellRendererResolver;
121
+ }
122
+
123
+ /**
124
+ * Normalise a lookup_columns entry (string | LookupColumnDef) into a
125
+ * concrete LookupColumnDef object.
126
+ */
127
+ function normalizeColumn(col: string | LookupColumnDef): LookupColumnDef {
128
+ return typeof col === 'string' ? { field: col } : col;
129
+ }
130
+
131
+ /**
132
+ * Pretty-print a field name as a column header label.
133
+ * Converts snake_case / camelCase to Title Case.
134
+ */
135
+ function fieldToLabel(field: string): string {
136
+ return field
137
+ .replace(/_/g, ' ')
138
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
139
+ .replace(/\b\w/g, c => c.toUpperCase());
140
+ }
141
+
142
+ /**
143
+ * Convert LookupFilterDef[] to a Record<string, any> compatible with
144
+ * QueryParams.$filter. Supports operator mapping for eq/ne/gt/lt/gte/lte/
145
+ * contains/in/notIn.
146
+ */
147
+ function lookupFiltersToRecord(
148
+ filters: LookupFilterDef[],
149
+ ): Record<string, any> {
150
+ const result: Record<string, any> = {};
151
+ for (const f of filters) {
152
+ switch (f.operator) {
153
+ case 'eq':
154
+ result[f.field] = f.value;
155
+ break;
156
+ case 'ne':
157
+ result[f.field] = { $ne: f.value };
158
+ break;
159
+ case 'gt':
160
+ result[f.field] = { $gt: f.value };
161
+ break;
162
+ case 'lt':
163
+ result[f.field] = { $lt: f.value };
164
+ break;
165
+ case 'gte':
166
+ result[f.field] = { $gte: f.value };
167
+ break;
168
+ case 'lte':
169
+ result[f.field] = { $lte: f.value };
170
+ break;
171
+ case 'contains':
172
+ result[f.field] = { $contains: f.value };
173
+ break;
174
+ case 'in':
175
+ result[f.field] = { $in: f.value };
176
+ break;
177
+ case 'notIn':
178
+ result[f.field] = { $nin: f.value };
179
+ break;
180
+ }
181
+ }
182
+ return result;
183
+ }
184
+
185
+ /**
186
+ * Convert user-entered filter bar values into a $filter Record.
187
+ * Each key is a field name, each value the user-entered value.
188
+ * Empty/null values are ignored.
189
+ */
190
+ function filterValuesToRecord(
191
+ values: Record<string, any>,
192
+ filterColumns: RecordPickerFilterColumn[],
193
+ ): Record<string, any> {
194
+ const result: Record<string, any> = {};
195
+ for (const col of filterColumns) {
196
+ const v = values[col.field];
197
+ if (v === undefined || v === null || v === '') continue;
198
+ if (col.type === 'boolean') {
199
+ result[col.field] = Boolean(v);
200
+ } else if (col.type === 'text') {
201
+ result[col.field] = { $contains: v };
202
+ } else {
203
+ result[col.field] = v;
204
+ }
205
+ }
206
+ return result;
207
+ }
208
+
209
+ export interface RecordPickerDialogProps {
210
+ /** Whether the dialog is open */
211
+ open: boolean;
212
+ /** Called when the dialog should close */
213
+ onOpenChange: (open: boolean) => void;
214
+
215
+ /** Dialog title */
216
+ title?: string;
217
+ /** Allow multiple selection */
218
+ multiple?: boolean;
219
+
220
+ /** DataSource to fetch records from */
221
+ dataSource: DataSource;
222
+ /** Object name to query (e.g. 'customers') */
223
+ objectName: string;
224
+
225
+ /** Columns to display. Defaults to [displayField, descriptionField]. */
226
+ columns?: Array<string | LookupColumnDef>;
227
+ /** Primary display field (default: 'name') */
228
+ displayField?: string;
229
+ /** Record id field (default: 'id') */
230
+ idField?: string;
231
+
232
+ /** Page size (default: 10) */
233
+ pageSize?: number;
234
+
235
+ /** Currently selected value(s) */
236
+ value?: any;
237
+ /** Called when selection changes */
238
+ onSelect: (value: any) => void;
239
+
240
+ /**
241
+ * Called with the full record objects corresponding to the selected value(s).
242
+ * Useful for parent components (e.g. LookupField) that need display data
243
+ * (labels, descriptions) for the selected records beyond just their IDs.
244
+ */
245
+ onSelectRecords?: (records: any[]) => void;
246
+
247
+ /**
248
+ * Base filters applied to every query.
249
+ * Converted from LookupFieldMetadata.lookup_filters.
250
+ * Restricts which records are selectable (e.g. only active records).
251
+ */
252
+ lookupFilters?: LookupFilterDef[];
253
+
254
+ /**
255
+ * Cell renderer resolver function.
256
+ * When provided, columns with a `type` property will be rendered using the
257
+ * resolved cell renderer (e.g. badges for select, formatted currency, etc.).
258
+ * Typically pass `getCellRenderer` from @object-ui/fields.
259
+ */
260
+ cellRenderer?: CellRendererResolver;
261
+
262
+ /**
263
+ * Filter bar column definitions.
264
+ * When provided, shows an inline filter bar below the search input.
265
+ * Columns can include type-specific inputs (text, number, select, date, boolean).
266
+ */
267
+ filterColumns?: RecordPickerFilterColumn[];
268
+
269
+ /**
270
+ * Custom filter bar renderer slot.
271
+ * When provided, replaces the built-in filter bar with a custom component
272
+ * (e.g. FilterUI from @object-ui/plugin-view).
273
+ * Receives filter state and callbacks via RecordPickerFilterBarProps.
274
+ *
275
+ * @example
276
+ * renderFilterBar={(props) => (
277
+ * <FilterUI
278
+ * schema={{ type: 'filter-ui', filters: props.filterColumns, layout: 'inline' }}
279
+ * onChange={(values) => Object.entries(values).forEach(([k, v]) => props.onChange(k, v))}
280
+ * />
281
+ * )}
282
+ */
283
+ renderFilterBar?: (props: RecordPickerFilterBarProps) => React.ReactNode;
284
+
285
+ /**
286
+ * Custom grid renderer slot.
287
+ * When provided, replaces the built-in table with a custom grid component
288
+ * (e.g. ObjectGrid from @object-ui/plugin-grid).
289
+ * Receives data, columns, and interaction callbacks via RecordPickerGridSlotProps.
290
+ *
291
+ * @example
292
+ * renderGrid={(props) => (
293
+ * <ObjectGrid
294
+ * schema={{ type: 'object-grid', objectName, columns: props.columns }}
295
+ * dataSource={dataSource}
296
+ * />
297
+ * )}
298
+ */
299
+ renderGrid?: (props: RecordPickerGridSlotProps) => React.ReactNode;
300
+ }
301
+
302
+ /**
303
+ * RecordPickerDialog — Enterprise-grade record selection dialog.
304
+ *
305
+ * Renders records in a table with multi-column display, search,
306
+ * pagination, column sorting, keyboard navigation, loading/error/empty
307
+ * states, and single/multi-select. Responsive: mobile-friendly width
308
+ * via Tailwind breakpoints.
309
+ */
310
+ export function RecordPickerDialog({
311
+ open,
312
+ onOpenChange,
313
+ title = 'Select Record',
314
+ multiple = false,
315
+ dataSource,
316
+ objectName,
317
+ columns: columnsProp,
318
+ displayField = 'name',
319
+ idField = 'id',
320
+ pageSize = DEFAULT_PAGE_SIZE,
321
+ value,
322
+ onSelect,
323
+ onSelectRecords,
324
+ lookupFilters,
325
+ cellRenderer,
326
+ filterColumns,
327
+ renderFilterBar,
328
+ renderGrid,
329
+ }: RecordPickerDialogProps) {
330
+ const [records, setRecords] = useState<any[]>([]);
331
+ const [loading, setLoading] = useState(false);
332
+ const [error, setError] = useState<string | null>(null);
333
+ const [totalCount, setTotalCount] = useState(0);
334
+ const [searchQuery, setSearchQuery] = useState('');
335
+ const [currentPage, setCurrentPage] = useState(1);
336
+ const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
337
+
338
+ // Column sorting state
339
+ const [sortField, setSortField] = useState<string | null>(null);
340
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
341
+
342
+ // For multi-select, track pending selections before confirming
343
+ const [pendingSelection, setPendingSelection] = useState<Set<any>>(new Set());
344
+
345
+ // Cache selected record objects across page navigations (multi-select).
346
+ // When the user toggles a row the full record is stored here so that
347
+ // onSelectRecords can return complete objects even after paging away.
348
+ const selectedRecordsMap = useRef<Map<any, any>>(new Map());
349
+
350
+ // Keyboard navigation: focused row index
351
+ const [focusedRow, setFocusedRow] = useState(-1);
352
+ const tableBodyRef = useRef<HTMLTableSectionElement>(null);
353
+
354
+ // Filter bar state
355
+ const [filterBarOpen, setFilterBarOpen] = useState(false);
356
+ const [filterValues, setFilterValues] = useState<Record<string, any>>({});
357
+
358
+ // Column resize state: widths keyed by field name
359
+ const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
360
+ const resizeRef = useRef<{ field: string; startX: number; startWidth: number } | null>(null);
361
+
362
+ // Page jump input state
363
+ const [pageJumpValue, setPageJumpValue] = useState('');
364
+
365
+ // Resolved columns
366
+ const resolvedColumns = useMemo<LookupColumnDef[]>(() => {
367
+ if (columnsProp && columnsProp.length > 0) {
368
+ return columnsProp.map(normalizeColumn);
369
+ }
370
+ // Auto-infer: just use displayField
371
+ return [{ field: displayField, label: fieldToLabel(displayField) }];
372
+ }, [columnsProp, displayField]);
373
+
374
+ // Auto-generate filter columns from lookupFilters when no explicit filterColumns given.
375
+ // Each LookupFilterDef becomes a filterable field with inferred type.
376
+ const effectiveFilterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
377
+ if (filterColumns && filterColumns.length > 0) return filterColumns;
378
+ // Auto-derive from lookupFilters: each filter entry becomes a filterable field
379
+ if (lookupFilters && lookupFilters.length > 0) {
380
+ return lookupFilters.map(f => {
381
+ // Infer filter input type from value type first, then fall back to operator
382
+ let type: RecordPickerFilterColumn['type'] = 'text';
383
+ if (typeof f.value === 'boolean') {
384
+ type = 'boolean';
385
+ } else if (Array.isArray(f.value)) {
386
+ type = 'select';
387
+ } else if (typeof f.value === 'number') {
388
+ type = 'number';
389
+ } else if (f.operator === 'gt' || f.operator === 'lt' || f.operator === 'gte' || f.operator === 'lte') {
390
+ type = 'number';
391
+ } else if (f.operator === 'in' || f.operator === 'notIn') {
392
+ type = 'select';
393
+ }
394
+ return {
395
+ field: f.field,
396
+ label: fieldToLabel(f.field),
397
+ type,
398
+ // For array values (in/notIn), derive selectable options
399
+ ...(Array.isArray(f.value) ? {
400
+ options: (f.value as any[]).map(v => {
401
+ if (v != null && typeof v === 'object') {
402
+ const obj = v as Record<string, unknown>;
403
+ return { label: String(obj.name || obj.label || obj.title || v), value: v };
404
+ }
405
+ return { label: String(v), value: v };
406
+ }),
407
+ } : {}),
408
+ };
409
+ });
410
+ }
411
+ return undefined;
412
+ }, [filterColumns, lookupFilters]);
413
+
414
+ // Merge base lookup_filters with user filter bar values
415
+ const mergedFilter = useMemo<Record<string, any> | undefined>(() => {
416
+ const baseFilter = lookupFilters?.length
417
+ ? lookupFiltersToRecord(lookupFilters)
418
+ : {};
419
+ const userFilter = effectiveFilterColumns?.length
420
+ ? filterValuesToRecord(filterValues, effectiveFilterColumns)
421
+ : {};
422
+ const combined = { ...baseFilter, ...userFilter };
423
+ return Object.keys(combined).length > 0 ? combined : undefined;
424
+ }, [lookupFilters, effectiveFilterColumns, filterValues]);
425
+
426
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
427
+
428
+ // Fetch records
429
+ const fetchRecords = useCallback(
430
+ async (search?: string, page = 1, sort?: { field: string; direction: 'asc' | 'desc' } | null, customFilter?: Record<string, any>) => {
431
+ if (!dataSource || !objectName) return;
432
+
433
+ setLoading(true);
434
+ setError(null);
435
+
436
+ try {
437
+ const params: QueryParams = {
438
+ $top: pageSize,
439
+ $skip: (page - 1) * pageSize,
440
+ };
441
+ if (search && search.trim()) {
442
+ params.$search = search.trim();
443
+ }
444
+ if (sort) {
445
+ params.$orderby = { [sort.field]: sort.direction };
446
+ }
447
+ // Inject filters (lookup_filters + filter bar values)
448
+ const effectiveFilter = customFilter !== undefined ? customFilter : mergedFilter;
449
+ if (effectiveFilter && Object.keys(effectiveFilter).length > 0) {
450
+ params.$filter = effectiveFilter;
451
+ }
452
+
453
+ const result = await dataSource.find(objectName, params);
454
+ const data: any[] = result?.data ?? result ?? [];
455
+
456
+ setRecords(data);
457
+ setTotalCount(result?.total ?? data.length);
458
+ setFocusedRow(-1);
459
+ } catch (err) {
460
+ const msg = err instanceof Error ? err.message : String(err);
461
+ setError(msg);
462
+ setRecords([]);
463
+ } finally {
464
+ setLoading(false);
465
+ }
466
+ },
467
+ [dataSource, objectName, pageSize, mergedFilter],
468
+ );
469
+
470
+ // Build current sort object for passing to fetchRecords
471
+ const currentSort = useMemo(
472
+ () => sortField ? { field: sortField, direction: sortDirection } : null,
473
+ [sortField, sortDirection],
474
+ );
475
+
476
+ // Reset state when dialog closes — separate from the fetch effect so that
477
+ // resetting currentPage / sortField (which are fetch-effect deps) does not
478
+ // re-trigger the fetch effect and cause cascading re-renders (React #185).
479
+ useEffect(() => {
480
+ if (!open) {
481
+ setSearchQuery('');
482
+ setCurrentPage(1);
483
+ setError(null);
484
+ setRecords([]);
485
+ setSortField(null);
486
+ setSortDirection('asc');
487
+ setFocusedRow(-1);
488
+ setFilterBarOpen(false);
489
+ setFilterValues({});
490
+ setColumnWidths({});
491
+ setPageJumpValue('');
492
+ setPendingSelection(new Set(
493
+ multiple ? (Array.isArray(value) ? value : []) : [],
494
+ ));
495
+ selectedRecordsMap.current.clear();
496
+ }
497
+ // Intentionally depends only on `open` — `multiple` and `value` are
498
+ // captured at close-time and don't need to trigger resets while closed.
499
+ // eslint-disable-next-line react-hooks/exhaustive-deps
500
+ }, [open]);
501
+
502
+ // Fetch when dialog is open and pagination / sort / filter deps change
503
+ useEffect(() => {
504
+ if (open) {
505
+ fetchRecords(searchQuery || undefined, currentPage, currentSort);
506
+ }
507
+ // `fetchRecords` and `searchQuery` are intentionally excluded:
508
+ // fetchRecords is stable (useCallback), searchQuery triggers its own
509
+ // debounced fetch in handleSearchChange.
510
+ // eslint-disable-next-line react-hooks/exhaustive-deps
511
+ }, [open, currentPage, currentSort, mergedFilter]);
512
+
513
+ // Initialize pending selection when dialog opens
514
+ useEffect(() => {
515
+ if (open && multiple) {
516
+ setPendingSelection(new Set(Array.isArray(value) ? value : []));
517
+ }
518
+ }, [open, multiple, value]);
519
+
520
+ // Debounced search
521
+ const handleSearchChange = useCallback(
522
+ (query: string) => {
523
+ setSearchQuery(query);
524
+ setCurrentPage(1);
525
+
526
+ if (debounceTimer.current) {
527
+ clearTimeout(debounceTimer.current);
528
+ }
529
+ debounceTimer.current = setTimeout(() => {
530
+ fetchRecords(query || undefined, 1, currentSort);
531
+ }, 300);
532
+ },
533
+ [fetchRecords, currentSort],
534
+ );
535
+
536
+ // Clean up debounce timer
537
+ useEffect(() => {
538
+ return () => {
539
+ if (debounceTimer.current) {
540
+ clearTimeout(debounceTimer.current);
541
+ }
542
+ };
543
+ }, []);
544
+
545
+ // Column sort handler
546
+ const handleSort = useCallback((field: string) => {
547
+ setSortField(prev => {
548
+ if (prev === field) {
549
+ // Toggle direction
550
+ setSortDirection(d => d === 'asc' ? 'desc' : 'asc');
551
+ return field;
552
+ }
553
+ setSortDirection('asc');
554
+ return field;
555
+ });
556
+ setCurrentPage(1);
557
+ }, []);
558
+
559
+ // Get record id
560
+ const getRecordId = useCallback(
561
+ (record: any) => record[idField] ?? record.id ?? record._id,
562
+ [idField],
563
+ );
564
+
565
+ // Check if a record is selected
566
+ const isSelected = useCallback(
567
+ (record: any) => {
568
+ const rid = getRecordId(record);
569
+ if (multiple) {
570
+ return pendingSelection.has(rid);
571
+ }
572
+ return value === rid;
573
+ },
574
+ [multiple, value, pendingSelection, getRecordId],
575
+ );
576
+
577
+ // Handle row click
578
+ const handleRowClick = useCallback(
579
+ (record: any) => {
580
+ const rid = getRecordId(record);
581
+
582
+ if (multiple) {
583
+ setPendingSelection(prev => {
584
+ const next = new Set(prev);
585
+ if (next.has(rid)) {
586
+ next.delete(rid);
587
+ selectedRecordsMap.current.delete(rid);
588
+ } else {
589
+ next.add(rid);
590
+ selectedRecordsMap.current.set(rid, record);
591
+ }
592
+ return next;
593
+ });
594
+ } else {
595
+ // Single select — immediately close
596
+ onSelect(rid);
597
+ onSelectRecords?.([record]);
598
+ onOpenChange(false);
599
+ }
600
+ },
601
+ [multiple, getRecordId, onSelect, onSelectRecords, onOpenChange],
602
+ );
603
+
604
+ // Confirm multi-select
605
+ const handleConfirm = useCallback(() => {
606
+ const ids = Array.from(pendingSelection);
607
+ onSelect(ids);
608
+ // Build the full record array from the cache for the caller
609
+ const selectedRecords = ids
610
+ .map(id => selectedRecordsMap.current.get(id))
611
+ .filter(Boolean);
612
+ onSelectRecords?.(selectedRecords);
613
+ onOpenChange(false);
614
+ }, [pendingSelection, onSelect, onSelectRecords, onOpenChange]);
615
+
616
+ // Page navigation
617
+ const handlePrevPage = useCallback(() => {
618
+ setCurrentPage(p => Math.max(1, p - 1));
619
+ }, []);
620
+
621
+ const handleNextPage = useCallback(() => {
622
+ setCurrentPage(p => Math.min(totalPages, p + 1));
623
+ }, [totalPages]);
624
+
625
+ // Page jump handler
626
+ const handlePageJump = useCallback(
627
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
628
+ if (e.key !== 'Enter') return;
629
+ const page = parseInt(pageJumpValue, 10);
630
+ if (!isNaN(page) && page >= 1 && page <= totalPages) {
631
+ setCurrentPage(page);
632
+ }
633
+ setPageJumpValue('');
634
+ },
635
+ [pageJumpValue, totalPages],
636
+ );
637
+
638
+ // Keyboard navigation for the table
639
+ const handleTableKeyDown = useCallback(
640
+ (e: React.KeyboardEvent) => {
641
+ if (records.length === 0) return;
642
+
643
+ if (e.key === 'ArrowDown') {
644
+ e.preventDefault();
645
+ setFocusedRow(prev => Math.min(prev + 1, records.length - 1));
646
+ } else if (e.key === 'ArrowUp') {
647
+ e.preventDefault();
648
+ setFocusedRow(prev => Math.max(prev - 1, 0));
649
+ } else if (e.key === 'Enter' || e.key === ' ') {
650
+ e.preventDefault();
651
+ if (focusedRow >= 0 && focusedRow < records.length) {
652
+ handleRowClick(records[focusedRow]);
653
+ }
654
+ }
655
+ },
656
+ [records, focusedRow, handleRowClick],
657
+ );
658
+
659
+ // Scroll focused row into view
660
+ useEffect(() => {
661
+ if (focusedRow >= 0 && tableBodyRef.current) {
662
+ const row = tableBodyRef.current.querySelector(`[data-row-index="${focusedRow}"]`);
663
+ if (row && typeof row.scrollIntoView === 'function') {
664
+ row.scrollIntoView({ block: 'nearest' });
665
+ }
666
+ }
667
+ }, [focusedRow]);
668
+
669
+ // Get display value for a cell — type-aware rendering when cellRenderer is provided
670
+ const renderCellContent = useCallback((record: any, col: LookupColumnDef): React.ReactNode => {
671
+ const val = record[col.field];
672
+
673
+ // Use type-aware renderer when column type and resolver are available
674
+ if (col.type && cellRenderer) {
675
+ const Renderer = cellRenderer(col.type);
676
+ if (Renderer) {
677
+ return <Renderer value={val} field={{ name: col.field, type: col.type } as any} />;
678
+ }
679
+ }
680
+
681
+ // Fallback: plain text formatting
682
+ if (val === null || val === undefined) return '';
683
+ if (typeof val === 'object') {
684
+ // Handle MongoDB types / expanded references
685
+ if (val.$numberDecimal) return String(Number(val.$numberDecimal));
686
+ if (val.$oid) return String(val.$oid);
687
+ if (val.$date) return new Date(val.$date).toLocaleDateString();
688
+ if (val.name || val.label) return String(val.name || val.label);
689
+ return JSON.stringify(val);
690
+ }
691
+ if (typeof val === 'boolean') return val ? 'Yes' : 'No';
692
+ return String(val);
693
+ }, [cellRenderer]);
694
+
695
+ // Render sort indicator for a column
696
+ const renderSortIcon = useCallback((field: string) => {
697
+ if (sortField !== field) {
698
+ return <ArrowUpDown className="ml-1 size-3 opacity-40" />;
699
+ }
700
+ return sortDirection === 'asc'
701
+ ? <ArrowUp className="ml-1 size-3" />
702
+ : <ArrowDown className="ml-1 size-3" />;
703
+ }, [sortField, sortDirection]);
704
+
705
+ // Column resize: mouse-down on drag handle
706
+ const handleResizeStart = useCallback(
707
+ (e: React.MouseEvent, field: string, currentWidth: number) => {
708
+ e.preventDefault();
709
+ e.stopPropagation();
710
+ resizeRef.current = { field, startX: e.clientX, startWidth: currentWidth };
711
+
712
+ const handleMouseMove = (moveEvt: MouseEvent) => {
713
+ if (!resizeRef.current) return;
714
+ const delta = moveEvt.clientX - resizeRef.current.startX;
715
+ const newWidth = Math.max(MIN_COL_WIDTH, resizeRef.current.startWidth + delta);
716
+ setColumnWidths(prev => ({ ...prev, [resizeRef.current!.field]: newWidth }));
717
+ };
718
+
719
+ const handleMouseUp = () => {
720
+ resizeRef.current = null;
721
+ document.removeEventListener('mousemove', handleMouseMove);
722
+ document.removeEventListener('mouseup', handleMouseUp);
723
+ };
724
+
725
+ document.addEventListener('mousemove', handleMouseMove);
726
+ document.addEventListener('mouseup', handleMouseUp);
727
+ },
728
+ [],
729
+ );
730
+
731
+ // Filter bar: update a single field value
732
+ const handleFilterChange = useCallback(
733
+ (field: string, val: any) => {
734
+ setFilterValues(prev => ({ ...prev, [field]: val }));
735
+ setCurrentPage(1);
736
+ },
737
+ [],
738
+ );
739
+
740
+ // Filter bar: clear all filter values
741
+ const handleFilterClear = useCallback(() => {
742
+ setFilterValues({});
743
+ setCurrentPage(1);
744
+ }, []);
745
+
746
+ // Active filter count for badge
747
+ const activeFilterCount = useMemo(
748
+ () => Object.values(filterValues).filter(v => v !== undefined && v !== null && v !== '').length,
749
+ [filterValues],
750
+ );
751
+
752
+ // Render a single filter bar input
753
+ const renderFilterInput = useCallback(
754
+ (col: RecordPickerFilterColumn) => {
755
+ const val = filterValues[col.field];
756
+ const label = col.label || fieldToLabel(col.field);
757
+
758
+ switch (col.type) {
759
+ case 'select':
760
+ return (
761
+ <div className="space-y-1">
762
+ <Label className="text-xs text-muted-foreground">{label}</Label>
763
+ <Select
764
+ value={val !== undefined && val !== null ? String(val) : ''}
765
+ onValueChange={v => handleFilterChange(col.field, v)}
766
+ >
767
+ <SelectTrigger className="h-8 text-xs">
768
+ <SelectValue placeholder={`Filter ${label}`} />
769
+ </SelectTrigger>
770
+ <SelectContent>
771
+ {col.options?.map(opt => (
772
+ <SelectItem key={String(opt.value)} value={String(opt.value)}>
773
+ {opt.label}
774
+ </SelectItem>
775
+ ))}
776
+ </SelectContent>
777
+ </Select>
778
+ </div>
779
+ );
780
+ case 'number':
781
+ return (
782
+ <div className="space-y-1">
783
+ <Label className="text-xs text-muted-foreground">{label}</Label>
784
+ <Input
785
+ type="number"
786
+ className="h-8 text-xs"
787
+ value={val ?? ''}
788
+ placeholder={`Filter ${label}`}
789
+ onChange={e => {
790
+ const raw = e.target.value;
791
+ handleFilterChange(col.field, raw === '' ? '' : Number(raw));
792
+ }}
793
+ />
794
+ </div>
795
+ );
796
+ case 'date':
797
+ return (
798
+ <div className="space-y-1">
799
+ <Label className="text-xs text-muted-foreground">{label}</Label>
800
+ <Input
801
+ type="date"
802
+ className="h-8 text-xs"
803
+ value={val ?? ''}
804
+ onChange={e => handleFilterChange(col.field, e.target.value)}
805
+ />
806
+ </div>
807
+ );
808
+ case 'boolean':
809
+ return (
810
+ <div className="space-y-1">
811
+ <Label className="text-xs text-muted-foreground">{label}</Label>
812
+ <div className="flex items-center gap-2 h-8">
813
+ <Checkbox
814
+ checked={Boolean(val)}
815
+ onCheckedChange={checked => handleFilterChange(col.field, Boolean(checked))}
816
+ />
817
+ <span className="text-xs text-muted-foreground">Yes</span>
818
+ </div>
819
+ </div>
820
+ );
821
+ case 'text':
822
+ default:
823
+ return (
824
+ <div className="space-y-1">
825
+ <Label className="text-xs text-muted-foreground">{label}</Label>
826
+ <Input
827
+ className="h-8 text-xs"
828
+ value={val ?? ''}
829
+ placeholder={`Filter ${label}`}
830
+ onChange={e => handleFilterChange(col.field, e.target.value)}
831
+ />
832
+ </div>
833
+ );
834
+ }
835
+ },
836
+ [filterValues, handleFilterChange],
837
+ );
838
+
839
+ // Row background class logic: selected > odd-striped > default
840
+ const getRowBgClass = useCallback((selected: boolean, idx: number) => {
841
+ if (selected) return 'bg-primary/5 hover:bg-primary/10';
842
+ if (idx % 2 === 1) return 'bg-muted/20 hover:bg-accent/30';
843
+ return 'hover:bg-accent/30';
844
+ }, []);
845
+
846
+ return (
847
+ <Dialog open={open} onOpenChange={onOpenChange}>
848
+ <DialogContent
849
+ className="w-[95vw] sm:max-w-3xl lg:max-w-5xl max-h-[85vh] sm:max-h-[80vh] flex flex-col gap-0"
850
+ data-testid="record-picker-dialog"
851
+ >
852
+ <DialogHeader>
853
+ <DialogTitle>
854
+ {title}
855
+ {multiple && <span className="sr-only"> (multiple selection)</span>}
856
+ </DialogTitle>
857
+ </DialogHeader>
858
+
859
+ {/* Search bar */}
860
+ <div className="relative rounded-md border bg-muted/30 mb-3">
861
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
862
+ <Input
863
+ placeholder="Search..."
864
+ value={searchQuery}
865
+ onChange={(e) => handleSearchChange(e.target.value)}
866
+ className="pl-9 border-0 bg-transparent shadow-none focus-visible:ring-0"
867
+ data-testid="record-picker-search"
868
+ />
869
+ {loading && (
870
+ <Loader2
871
+ className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
872
+ data-testid="record-picker-loading-indicator"
873
+ />
874
+ )}
875
+ </div>
876
+
877
+ {/* Filter bar (inline) — supports external FilterUI via renderFilterBar slot */}
878
+ {effectiveFilterColumns && effectiveFilterColumns.length > 0 && (
879
+ <div className="py-2">
880
+ {renderFilterBar ? (
881
+ /* External filter bar (e.g. FilterUI from plugin-view) */
882
+ <div data-testid="record-picker-filter-bar">
883
+ {renderFilterBar({
884
+ filterColumns: effectiveFilterColumns,
885
+ values: filterValues,
886
+ onChange: handleFilterChange,
887
+ onClear: handleFilterClear,
888
+ activeCount: activeFilterCount,
889
+ })}
890
+ </div>
891
+ ) : (
892
+ /* Built-in filter bar (default) */
893
+ <>
894
+ <div className="flex items-center gap-2" data-testid="record-picker-filter-bar">
895
+ <Button
896
+ type="button"
897
+ variant={activeFilterCount > 0 ? 'secondary' : 'outline'}
898
+ size="sm"
899
+ className="gap-1.5 shrink-0"
900
+ onClick={() => setFilterBarOpen(prev => !prev)}
901
+ >
902
+ <SlidersHorizontal className="size-3.5" />
903
+ Filters
904
+ {activeFilterCount > 0 && (
905
+ <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">
906
+ {activeFilterCount}
907
+ </span>
908
+ )}
909
+ </Button>
910
+ {activeFilterCount > 0 && (
911
+ <Button
912
+ type="button"
913
+ variant="ghost"
914
+ size="sm"
915
+ className="gap-1 text-xs"
916
+ onClick={handleFilterClear}
917
+ >
918
+ <X className="size-3" />
919
+ Clear
920
+ </Button>
921
+ )}
922
+ </div>
923
+ {filterBarOpen && (
924
+ <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">
925
+ {effectiveFilterColumns.map(col => (
926
+ <div key={col.field}>{renderFilterInput(col)}</div>
927
+ ))}
928
+ </div>
929
+ )}
930
+ </>
931
+ )}
932
+ </div>
933
+ )}
934
+
935
+ {/* Error state */}
936
+ {error && (
937
+ <div className="flex flex-col items-center gap-2 py-4" role="alert">
938
+ <AlertCircle className="size-5 text-destructive" />
939
+ <p className="text-sm text-destructive">{error}</p>
940
+ <Button
941
+ variant="outline"
942
+ size="sm"
943
+ onClick={() => fetchRecords(searchQuery || undefined, currentPage, currentSort)}
944
+ type="button"
945
+ >
946
+ Retry
947
+ </Button>
948
+ </div>
949
+ )}
950
+
951
+ {/* Grid area — external ObjectGrid via renderGrid slot, or built-in table */}
952
+ {renderGrid ? (
953
+ /* External grid component (e.g. ObjectGrid from plugin-grid) */
954
+ <div className="flex-1 min-h-0" data-testid="record-picker-grid-slot">
955
+ {renderGrid({
956
+ columns: resolvedColumns,
957
+ records,
958
+ loading,
959
+ totalCount,
960
+ currentPage,
961
+ pageSize,
962
+ sortField,
963
+ sortDirection,
964
+ onSort: handleSort,
965
+ onPageChange: setCurrentPage,
966
+ onRowClick: handleRowClick,
967
+ isSelected,
968
+ multiple,
969
+ idField,
970
+ cellRenderer,
971
+ })}
972
+ </div>
973
+ ) : (
974
+ /* Built-in table (default) */
975
+ <>
976
+ {/* Skeleton loading state (initial) */}
977
+ {loading && records.length === 0 && !error && (
978
+ <div className="flex-1 overflow-hidden min-h-0 border rounded-md" role="status" aria-live="polite" data-testid="record-picker-skeleton">
979
+ <Table>
980
+ <TableHeader>
981
+ <TableRow className="bg-muted/40">
982
+ {multiple && <TableHead className="w-10" />}
983
+ {resolvedColumns.map(col => (
984
+ <TableHead key={col.field}>
985
+ <Skeleton className="h-4 w-20" />
986
+ </TableHead>
987
+ ))}
988
+ </TableRow>
989
+ </TableHeader>
990
+ <TableBody>
991
+ {Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => (
992
+ <TableRow key={i}>
993
+ {multiple && (
994
+ <TableCell className="w-10">
995
+ <Skeleton className="size-4 rounded" />
996
+ </TableCell>
997
+ )}
998
+ {resolvedColumns.map(col => (
999
+ <TableCell key={col.field}>
1000
+ <Skeleton className="h-4 w-full" />
1001
+ </TableCell>
1002
+ ))}
1003
+ </TableRow>
1004
+ ))}
1005
+ </TableBody>
1006
+ </Table>
1007
+ </div>
1008
+ )}
1009
+
1010
+ {/* Empty state */}
1011
+ {!loading && !error && records.length === 0 && (
1012
+ <div className="py-8 text-center">
1013
+ <p className="text-sm text-muted-foreground">No records found</p>
1014
+ </div>
1015
+ )}
1016
+
1017
+ {/* Table */}
1018
+ {!error && records.length > 0 && (
1019
+ <div
1020
+ className="relative flex-1 overflow-auto min-h-0 border rounded-md"
1021
+ tabIndex={0}
1022
+ onKeyDown={handleTableKeyDown}
1023
+ role="grid"
1024
+ aria-label="Records"
1025
+ >
1026
+ {/* Loading overlay for subsequent fetches (page/sort/filter) */}
1027
+ {loading && (
1028
+ <div
1029
+ className="absolute inset-0 z-10 flex items-center justify-center bg-background/60"
1030
+ data-testid="record-picker-loading-overlay"
1031
+ >
1032
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
1033
+ </div>
1034
+ )}
1035
+ <Table style={Object.keys(columnWidths).length > 0 ? { tableLayout: 'fixed' } : undefined}>
1036
+ <TableHeader className="sticky top-0 z-[5] bg-muted/50 [&_tr]:border-b" data-testid="record-picker-sticky-header">
1037
+ <TableRow>
1038
+ {multiple && (
1039
+ <TableHead className="w-10" />
1040
+ )}
1041
+ {resolvedColumns.map(col => {
1042
+ const w = columnWidths[col.field];
1043
+ const styleWidth = w ? { width: `${w}px`, minWidth: `${w}px` } : col.width ? { width: col.width } : undefined;
1044
+ return (
1045
+ <TableHead
1046
+ key={col.field}
1047
+ style={styleWidth}
1048
+ className="cursor-pointer select-none relative group text-xs font-semibold uppercase tracking-wider"
1049
+ onClick={() => handleSort(col.field)}
1050
+ aria-sort={sortField === col.field ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'}
1051
+ >
1052
+ <span className="inline-flex items-center">
1053
+ {col.label || fieldToLabel(col.field)}
1054
+ {renderSortIcon(col.field)}
1055
+ </span>
1056
+ {/* Column resize handle */}
1057
+ <span
1058
+ role="separator"
1059
+ aria-orientation="vertical"
1060
+ 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"
1061
+ onMouseDown={e => {
1062
+ const th = e.currentTarget.parentElement;
1063
+ const rect = th?.getBoundingClientRect();
1064
+ handleResizeStart(e, col.field, rect?.width ?? 100);
1065
+ }}
1066
+ onClick={e => e.stopPropagation()}
1067
+ data-testid={`resize-handle-${col.field}`}
1068
+ />
1069
+ </TableHead>
1070
+ );
1071
+ })}
1072
+ </TableRow>
1073
+ </TableHeader>
1074
+ <TableBody ref={tableBodyRef}>
1075
+ {records.map((record, idx) => {
1076
+ const rid = getRecordId(record);
1077
+ const selected = isSelected(record);
1078
+ const focused = idx === focusedRow;
1079
+
1080
+ return (
1081
+ <TableRow
1082
+ key={rid ?? idx}
1083
+ data-row-index={idx}
1084
+ className={cn(
1085
+ 'cursor-pointer transition-colors',
1086
+ getRowBgClass(selected, idx),
1087
+ focused && 'ring-2 ring-primary ring-inset',
1088
+ )}
1089
+ onClick={() => handleRowClick(record)}
1090
+ data-testid={`record-row-${rid}`}
1091
+ aria-selected={selected}
1092
+ >
1093
+ {multiple && (
1094
+ <TableCell className="w-10">
1095
+ {selected && <Check className="size-4 text-primary" />}
1096
+ </TableCell>
1097
+ )}
1098
+ {resolvedColumns.map(col => (
1099
+ <TableCell key={col.field} className="py-2.5">
1100
+ {renderCellContent(record, col)}
1101
+ </TableCell>
1102
+ ))}
1103
+ </TableRow>
1104
+ );
1105
+ })}
1106
+ </TableBody>
1107
+ </Table>
1108
+ </div>
1109
+ )}
1110
+
1111
+ {/* Pagination — fixed bottom bar */}
1112
+ {!error && totalCount > 0 && (
1113
+ <div
1114
+ className="flex items-center justify-between text-sm text-muted-foreground border-t pt-3 mt-2 px-1"
1115
+ data-testid="record-picker-pagination"
1116
+ >
1117
+ <span>
1118
+ {totalCount} {totalCount === 1 ? 'record' : 'records'}
1119
+ {totalPages > 1 && ` · Page ${currentPage} of ${totalPages}`}
1120
+ </span>
1121
+ {totalPages > 1 && (
1122
+ <div className="flex items-center gap-1.5">
1123
+ <Button
1124
+ variant="outline"
1125
+ size="icon"
1126
+ className="size-7"
1127
+ onClick={handlePrevPage}
1128
+ disabled={currentPage <= 1}
1129
+ type="button"
1130
+ aria-label="Previous page"
1131
+ >
1132
+ <ChevronLeft className="size-4" />
1133
+ </Button>
1134
+ <Input
1135
+ className="h-7 w-12 text-center text-xs px-1"
1136
+ placeholder={String(currentPage)}
1137
+ value={pageJumpValue}
1138
+ onChange={e => setPageJumpValue(e.target.value)}
1139
+ onKeyDown={handlePageJump}
1140
+ aria-label="Jump to page"
1141
+ data-testid="record-picker-page-jump"
1142
+ />
1143
+ <Button
1144
+ variant="outline"
1145
+ size="icon"
1146
+ className="size-7"
1147
+ onClick={handleNextPage}
1148
+ disabled={currentPage >= totalPages}
1149
+ type="button"
1150
+ aria-label="Next page"
1151
+ >
1152
+ <ChevronRight className="size-4" />
1153
+ </Button>
1154
+ </div>
1155
+ )}
1156
+ </div>
1157
+ )}
1158
+ </>
1159
+ )}
1160
+
1161
+ {/* Multi-select confirmation */}
1162
+ {multiple && (
1163
+ <DialogFooter>
1164
+ <div className="flex items-center gap-2 w-full justify-between">
1165
+ <span className="text-sm text-muted-foreground">
1166
+ {pendingSelection.size} selected
1167
+ </span>
1168
+ <div className="flex gap-2">
1169
+ <Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
1170
+ Cancel
1171
+ </Button>
1172
+ <Button type="button" onClick={handleConfirm}>
1173
+ Confirm
1174
+ </Button>
1175
+ </div>
1176
+ </div>
1177
+ </DialogFooter>
1178
+ )}
1179
+ </DialogContent>
1180
+ </Dialog>
1181
+ );
1182
+ }