@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.
- package/dist/AddressField-B6fPb2Fs.js +3 -0
- package/dist/AddressField-Ctsxkj35.cjs +1 -0
- package/dist/AutoNumberField-DXIYJfiB.js +3 -0
- package/dist/AutoNumberField-f76XU1bR.cjs +1 -0
- package/dist/AvatarField-BHpex3Wa.js +3 -0
- package/dist/AvatarField-CXOdYavl.cjs +1 -0
- package/dist/BooleanField-Bs0urhsx.js +3 -0
- package/dist/BooleanField-DeexI_O2.cjs +1 -0
- package/dist/CodeField-DDOMH8E8.cjs +1 -0
- package/dist/CodeField-tRACqngO.js +3 -0
- package/dist/ColorField-Bar6bOJw.js +3 -0
- package/dist/ColorField-CM9CxZ9h.cjs +1 -0
- package/dist/CurrencyField-CezTX_rA.cjs +1 -0
- package/dist/CurrencyField-D4VwXI9k.js +3 -0
- package/dist/DateField-DDUllj7w.cjs +1 -0
- package/dist/DateField-DjGiK6BH.js +3 -0
- package/dist/DateTimeField-DBh-dYdR.js +3 -0
- package/dist/DateTimeField-DDqgVN3b.cjs +1 -0
- package/dist/EmailField-B-3nY8Pr.cjs +1 -0
- package/dist/EmailField-Cp6yJlpH.js +3 -0
- package/dist/FileField-C1bcL7tu.cjs +1 -0
- package/dist/FileField-DUgt6ai3.js +3 -0
- package/dist/FormulaField-BLV1KgkQ.js +3 -0
- package/dist/FormulaField-CsbQIZ7S.cjs +1 -0
- package/dist/GeolocationField-BbIRH51u.js +3 -0
- package/dist/GeolocationField-UFu9VR-x.cjs +1 -0
- package/dist/GridField-2m74haE0.cjs +1 -0
- package/dist/GridField-O3Jd1NuJ.js +3 -0
- package/dist/ImageField-Cna7g6KL.cjs +1 -0
- package/dist/ImageField-rc1Nzcvb.js +3 -0
- package/dist/LocationField-DpRe9QlH.cjs +1 -0
- package/dist/LocationField-QZPPYo0J.js +3 -0
- package/dist/LookupField-BX4sK8NG.js +904 -0
- package/dist/LookupField-C9rPrHi_.cjs +1 -0
- package/dist/MasterDetailField-BXncIT1s.js +3 -0
- package/dist/MasterDetailField-TNP2825Z.cjs +1 -0
- package/dist/NumberField-DCHTzmDG.js +3 -0
- package/dist/NumberField-SqRwbcY8.cjs +1 -0
- package/dist/ObjectField-BEUHpn_X.js +3 -0
- package/dist/ObjectField-BWZwaFIG.cjs +1 -0
- package/dist/PasswordField-BZ_OeQyw.js +3 -0
- package/dist/PasswordField-kLp-ZyPm.cjs +1 -0
- package/dist/PercentField-B2t2knlc.js +3 -0
- package/dist/PercentField-BzhD6tQm.cjs +1 -0
- package/dist/PhoneField-CirvuysP.cjs +1 -0
- package/dist/PhoneField-CkrCFmcO.js +3 -0
- package/dist/QRCodeField-C4OOLCbE.cjs +1 -0
- package/dist/QRCodeField-CQoGow8y.js +3 -0
- package/dist/RatingField-CZG8SgQ8.cjs +1 -0
- package/dist/RatingField-DecDJkou.js +3 -0
- package/dist/RichTextField-Bog2i4NN.cjs +1 -0
- package/dist/RichTextField-b-8QbNCJ.js +3 -0
- package/dist/SelectField-CAa6c0cB.cjs +1 -0
- package/dist/SelectField-DMy5IxRc.js +3 -0
- package/dist/SignatureField-BvTPE7Dn.js +3 -0
- package/dist/SignatureField-jsb-0AYp.cjs +1 -0
- package/dist/SliderField-B7WvKFv7.js +3 -0
- package/dist/SliderField-DRjEJSxK.cjs +1 -0
- package/dist/SummaryField-DA16Edq3.cjs +1 -0
- package/dist/SummaryField-DVSVfKmY.js +3 -0
- package/dist/TextAreaField--ut87UiB.js +3 -0
- package/dist/TextAreaField-Cxp3DFC6.cjs +1 -0
- package/dist/TextField-CkEDGbkQ.js +3 -0
- package/dist/TextField-aLAzBaLZ.cjs +1 -0
- package/dist/TimeField-D0lF0y_I.cjs +1 -0
- package/dist/TimeField-DA_S6lQV.js +3 -0
- package/dist/UrlField-D0ugmD3f.cjs +1 -0
- package/dist/UrlField-DCnXHR1A.js +3 -0
- package/dist/UserField-DGlorzyG.cjs +1 -0
- package/dist/UserField-V0GmvyGV.js +3 -0
- package/dist/VectorField-BSD85ntU.cjs +1 -0
- package/dist/VectorField-CVi5yrrc.js +3 -0
- package/dist/fields/src/index.d.ts +1 -0
- package/dist/fields/src/record-picker-dialog.stories.d.ts +25 -0
- package/dist/fields/src/record-picker.test.d.ts +1 -0
- package/dist/fields/src/widgets/RecordPickerDialog.d.ts +166 -0
- package/dist/fields/src/widgets/_cell-renderer-bridge.d.ts +3 -0
- package/dist/index.cjs +4 -9
- package/dist/index.js +2229 -2850
- package/dist/jsx-runtime-DHCF5ANT.cjs +6 -0
- package/dist/jsx-runtime-MxADXuRH.js +189 -0
- package/package.json +7 -6
- package/src/index.tsx +6 -0
- package/src/record-picker-dialog.stories.tsx +160 -0
- package/src/record-picker.test.tsx +1696 -0
- package/src/widgets/LookupField.tsx +254 -150
- package/src/widgets/RecordPickerDialog.tsx +1182 -0
- 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
|
+
}
|