@object-ui/fields 3.1.2 → 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 +8 -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/LookupField.d.ts +13 -2
- 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 -2682
- package/dist/jsx-runtime-DHCF5ANT.cjs +6 -0
- package/dist/jsx-runtime-MxADXuRH.js +189 -0
- package/package.json +8 -7
- package/src/__tests__/cell-renderers.test.tsx +133 -4
- package/src/complex-widgets.test.tsx +426 -2
- package/src/index.tsx +76 -22
- package/src/record-picker-dialog.stories.tsx +160 -0
- package/src/record-picker.test.tsx +1696 -0
- package/src/widgets/LookupField.tsx +485 -80
- package/src/widgets/RecordPickerDialog.tsx +1182 -0
- package/src/widgets/_cell-renderer-bridge.ts +19 -0
|
@@ -1,73 +1,338 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Button,
|
|
4
|
-
Dialog,
|
|
5
|
-
DialogContent,
|
|
6
|
-
DialogHeader,
|
|
7
|
-
DialogTitle,
|
|
8
|
-
DialogTrigger,
|
|
9
4
|
Input,
|
|
10
|
-
Badge
|
|
5
|
+
Badge,
|
|
6
|
+
Popover,
|
|
7
|
+
PopoverTrigger,
|
|
8
|
+
PopoverContent,
|
|
11
9
|
} from '@object-ui/components';
|
|
12
|
-
import { Search, X } from 'lucide-react';
|
|
10
|
+
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
|
|
13
11
|
import { FieldWidgetProps } from './types';
|
|
12
|
+
import type { DataSource, QueryParams, LookupColumnDef } from '@object-ui/types';
|
|
13
|
+
import { RecordPickerDialog } from './RecordPickerDialog';
|
|
14
|
+
import type { RecordPickerFilterColumn } from './RecordPickerDialog';
|
|
15
|
+
import { getCellRendererResolver } from './_cell-renderer-bridge';
|
|
16
|
+
import { SchemaRendererContext as ImportedSchemaRendererContext } from '@object-ui/react';
|
|
14
17
|
|
|
15
|
-
interface LookupOption {
|
|
18
|
+
export interface LookupOption {
|
|
16
19
|
value: string | number;
|
|
17
20
|
label: string;
|
|
21
|
+
description?: string;
|
|
18
22
|
[key: string]: any;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/** Page size for the quick-select popover typeahead */
|
|
26
|
+
const LOOKUP_PAGE_SIZE = 50;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SchemaRendererContext is created by @object-ui/react.
|
|
30
|
+
* Using a static import to be compatible with Next.js Turbopack SSR.
|
|
31
|
+
*/
|
|
32
|
+
const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
|
|
33
|
+
|
|
21
34
|
/**
|
|
22
|
-
*
|
|
23
|
-
|
|
35
|
+
* Map a raw record to a LookupOption using a display field and an id field.
|
|
36
|
+
*/
|
|
37
|
+
function recordToOption(
|
|
38
|
+
record: any,
|
|
39
|
+
displayField: string,
|
|
40
|
+
idField: string,
|
|
41
|
+
descriptionField?: string,
|
|
42
|
+
): LookupOption {
|
|
43
|
+
const val = record[idField] ?? record.id ?? record._id;
|
|
44
|
+
const label = record[displayField] ?? record.label ?? record.name ?? String(val);
|
|
45
|
+
const description = descriptionField ? record[descriptionField] : undefined;
|
|
46
|
+
return { value: val, label: String(label), description, ...record };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Map a LookupColumnDef.type to a filter input type for the filter bar.
|
|
51
|
+
* Returns undefined if the field type is not filterable.
|
|
52
|
+
*/
|
|
53
|
+
function mapFieldTypeToFilterType(
|
|
54
|
+
fieldType: string,
|
|
55
|
+
): RecordPickerFilterColumn['type'] | undefined {
|
|
56
|
+
const mapping: Record<string, RecordPickerFilterColumn['type']> = {
|
|
57
|
+
text: 'text',
|
|
58
|
+
number: 'number',
|
|
59
|
+
currency: 'number',
|
|
60
|
+
percent: 'number',
|
|
61
|
+
select: 'select',
|
|
62
|
+
status: 'select',
|
|
63
|
+
date: 'date',
|
|
64
|
+
datetime: 'date',
|
|
65
|
+
boolean: 'boolean',
|
|
66
|
+
};
|
|
67
|
+
return mapping[fieldType];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lookup field for selecting related records.
|
|
72
|
+
* Supports single and multi-select with search.
|
|
73
|
+
*
|
|
74
|
+
* When a `dataSource` is provided (either via props, via `field.dataSource`,
|
|
75
|
+
* or via SchemaRendererContext), the dialog will dynamically load records
|
|
76
|
+
* from the referenced object using `DataSource.find()`.
|
|
77
|
+
* Falls back to static `options` when no DataSource is available.
|
|
24
78
|
*/
|
|
25
79
|
export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
26
80
|
const [isOpen, setIsOpen] = useState(false);
|
|
27
81
|
const [searchQuery, setSearchQuery] = useState('');
|
|
28
82
|
|
|
83
|
+
// Dynamic data loading state
|
|
84
|
+
const [fetchedOptions, setFetchedOptions] = useState<LookupOption[]>([]);
|
|
85
|
+
const [loading, setLoading] = useState(false);
|
|
86
|
+
const [error, setError] = useState<string | null>(null);
|
|
87
|
+
const [totalCount, setTotalCount] = useState(0);
|
|
88
|
+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
89
|
+
|
|
90
|
+
// Records selected via RecordPickerDialog (Level 2).
|
|
91
|
+
// Stored as LookupOption so that findOption can resolve display labels
|
|
92
|
+
// even when the record wasn't part of the Level 1 popover fetch.
|
|
93
|
+
const [pickerResolvedRecords, setPickerResolvedRecords] = useState<LookupOption[]>([]);
|
|
94
|
+
|
|
95
|
+
// Arrow-key active index (-1 = none)
|
|
96
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
97
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
98
|
+
|
|
29
99
|
const lookupField = (field || (props as any).schema) as any;
|
|
30
|
-
const options: LookupOption[] = lookupField?.options || [];
|
|
31
|
-
const multiple = lookupField.multiple || false;
|
|
32
|
-
const displayField = lookupField.display_field || 'label';
|
|
33
100
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
101
|
+
// When rendered via createFieldRenderer wrapper the actual objectSchema field
|
|
102
|
+
// metadata (reference_to, display_field, etc.) lives at lookupField.field.
|
|
103
|
+
// Unwrap it so lookup-specific properties resolve correctly.
|
|
104
|
+
// ObjectStack convention uses `reference` while the types use `reference_to`,
|
|
105
|
+
// so we check for both property names.
|
|
106
|
+
const innerField = lookupField?.field;
|
|
107
|
+
const fieldMeta = (innerField && typeof innerField === 'object' && ('reference_to' in innerField || 'reference' in innerField || 'type' in innerField))
|
|
108
|
+
? innerField
|
|
109
|
+
: lookupField;
|
|
110
|
+
|
|
111
|
+
const staticOptions: LookupOption[] = fieldMeta?.options || [];
|
|
112
|
+
const multiple = fieldMeta?.multiple || false;
|
|
113
|
+
const displayField = fieldMeta?.display_field || fieldMeta?.reference_field || 'name';
|
|
114
|
+
const descriptionField: string | undefined = fieldMeta?.description_field;
|
|
115
|
+
const idField = fieldMeta?.id_field || 'id';
|
|
116
|
+
// ObjectStack convention uses `reference`; types define `reference_to` — support both
|
|
117
|
+
const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
|
|
118
|
+
|
|
119
|
+
// Enterprise Record Picker configuration
|
|
120
|
+
const lookupColumns: Array<string | LookupColumnDef> | undefined = fieldMeta?.lookup_columns;
|
|
121
|
+
const lookupPageSize: number | undefined = fieldMeta?.lookup_page_size;
|
|
122
|
+
const lookupFilters: import('@object-ui/types').LookupFilterDef[] | undefined = fieldMeta?.lookup_filters;
|
|
123
|
+
|
|
124
|
+
// Derive filter columns from lookup_columns that have type info
|
|
125
|
+
const filterColumns = useMemo<RecordPickerFilterColumn[] | undefined>(() => {
|
|
126
|
+
if (!lookupColumns) return undefined;
|
|
127
|
+
const cols: RecordPickerFilterColumn[] = [];
|
|
128
|
+
for (const c of lookupColumns) {
|
|
129
|
+
if (typeof c === 'object' && c.type) {
|
|
130
|
+
const filterType = mapFieldTypeToFilterType(c.type);
|
|
131
|
+
if (filterType) {
|
|
132
|
+
cols.push({
|
|
133
|
+
field: c.field,
|
|
134
|
+
label: c.label,
|
|
135
|
+
type: filterType,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return cols.length > 0 ? cols : undefined;
|
|
141
|
+
}, [lookupColumns]);
|
|
142
|
+
|
|
143
|
+
// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
|
|
144
|
+
const ctx = useContext(SchemaRendererContext);
|
|
145
|
+
const contextDataSource = ctx?.dataSource ?? null;
|
|
146
|
+
const dataSource: DataSource | null =
|
|
147
|
+
(props as any).dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
|
|
148
|
+
|
|
149
|
+
const hasDataSource = dataSource != null && typeof dataSource.find === 'function' && !!referenceTo;
|
|
150
|
+
|
|
151
|
+
// Optional create-new callback
|
|
152
|
+
const onCreateNew: ((searchQuery: string) => void) | undefined =
|
|
153
|
+
(props as any).onCreateNew ?? lookupField?.onCreateNew;
|
|
154
|
+
|
|
155
|
+
// State for the full Record Picker dialog (Level 2)
|
|
156
|
+
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
157
|
+
|
|
158
|
+
// Determine which options to display
|
|
159
|
+
const allOptions = hasDataSource ? fetchedOptions : staticOptions;
|
|
160
|
+
|
|
161
|
+
// For static options, filter locally based on search
|
|
162
|
+
const filteredOptions = useMemo(() => {
|
|
163
|
+
if (hasDataSource) return allOptions;
|
|
164
|
+
if (!searchQuery) return allOptions;
|
|
165
|
+
const q = searchQuery.toLowerCase();
|
|
166
|
+
return allOptions.filter(opt =>
|
|
167
|
+
opt.label.toLowerCase().includes(q) ||
|
|
168
|
+
(opt.description && opt.description.toLowerCase().includes(q))
|
|
169
|
+
);
|
|
170
|
+
}, [hasDataSource, allOptions, searchQuery]);
|
|
171
|
+
|
|
172
|
+
// Reset active index when options change
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
setActiveIndex(-1);
|
|
175
|
+
}, [filteredOptions.length]);
|
|
176
|
+
|
|
177
|
+
// Fetch data from DataSource
|
|
178
|
+
const fetchLookupData = useCallback(
|
|
179
|
+
async (search?: string) => {
|
|
180
|
+
if (!dataSource || !referenceTo) return;
|
|
181
|
+
|
|
182
|
+
setLoading(true);
|
|
183
|
+
setError(null);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const params: QueryParams = {
|
|
187
|
+
$top: LOOKUP_PAGE_SIZE,
|
|
188
|
+
};
|
|
189
|
+
if (search && search.trim()) {
|
|
190
|
+
params.$search = search.trim();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const result = await dataSource.find(referenceTo, params);
|
|
194
|
+
const records: any[] = result?.data ?? result ?? [];
|
|
195
|
+
const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField));
|
|
196
|
+
|
|
197
|
+
setFetchedOptions(mapped);
|
|
198
|
+
setTotalCount(result?.total ?? records.length);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
201
|
+
setError(msg);
|
|
202
|
+
setFetchedOptions([]);
|
|
203
|
+
} finally {
|
|
204
|
+
setLoading(false);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
[dataSource, referenceTo, displayField, idField, descriptionField],
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Fetch data when dialog opens.
|
|
211
|
+
// We intentionally depend only on `isOpen` so the effect fires once per
|
|
212
|
+
// open/close transition. `fetchLookupData` is stable-enough via its own
|
|
213
|
+
// useCallback deps; including it here would cause spurious re-fetches.
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (isOpen && hasDataSource) {
|
|
216
|
+
fetchLookupData(searchQuery || undefined);
|
|
217
|
+
}
|
|
218
|
+
// Clean up fetched data when dialog closes
|
|
219
|
+
if (!isOpen) {
|
|
220
|
+
setSearchQuery('');
|
|
221
|
+
setError(null);
|
|
222
|
+
setActiveIndex(-1);
|
|
223
|
+
}
|
|
224
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
225
|
+
}, [isOpen]);
|
|
226
|
+
|
|
227
|
+
// Debounced search
|
|
228
|
+
const handleSearchChange = useCallback(
|
|
229
|
+
(query: string) => {
|
|
230
|
+
setSearchQuery(query);
|
|
231
|
+
|
|
232
|
+
if (!hasDataSource) return;
|
|
233
|
+
|
|
234
|
+
if (debounceTimer.current) {
|
|
235
|
+
clearTimeout(debounceTimer.current);
|
|
236
|
+
}
|
|
237
|
+
debounceTimer.current = setTimeout(() => {
|
|
238
|
+
fetchLookupData(query || undefined);
|
|
239
|
+
}, 300);
|
|
240
|
+
},
|
|
241
|
+
[hasDataSource, fetchLookupData],
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Clean up debounce timer
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
return () => {
|
|
247
|
+
if (debounceTimer.current) {
|
|
248
|
+
clearTimeout(debounceTimer.current);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
// Get selected option(s) — check static, fetched, and picker-resolved options
|
|
254
|
+
const findOption = useCallback(
|
|
255
|
+
(v: any): LookupOption | undefined => {
|
|
256
|
+
return (
|
|
257
|
+
staticOptions.find(opt => opt.value === v) ??
|
|
258
|
+
fetchedOptions.find(opt => opt.value === v) ??
|
|
259
|
+
pickerResolvedRecords.find(opt => opt.value === v)
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
[staticOptions, fetchedOptions, pickerResolvedRecords],
|
|
37
263
|
);
|
|
38
264
|
|
|
39
|
-
// Get selected option(s)
|
|
40
265
|
const selectedOptions = multiple
|
|
41
|
-
? (Array.isArray(value) ? value : []).map(
|
|
42
|
-
|
|
43
|
-
).filter(Boolean)
|
|
44
|
-
: value ? [options.find(opt => opt.value === value)].filter(Boolean) : [];
|
|
266
|
+
? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean)
|
|
267
|
+
: value ? [findOption(value)].filter(Boolean) : [];
|
|
45
268
|
|
|
46
|
-
const handleSelect = (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
269
|
+
const handleSelect = useCallback(
|
|
270
|
+
(option: LookupOption) => {
|
|
271
|
+
if (multiple) {
|
|
272
|
+
const currentValues = Array.isArray(value) ? value : [];
|
|
273
|
+
const isSelected = currentValues.includes(option.value);
|
|
274
|
+
|
|
275
|
+
if (isSelected) {
|
|
276
|
+
onChange(currentValues.filter((v: any) => v !== option.value));
|
|
277
|
+
} else {
|
|
278
|
+
onChange([...currentValues, option.value]);
|
|
279
|
+
}
|
|
53
280
|
} else {
|
|
54
|
-
onChange(
|
|
281
|
+
onChange(option.value);
|
|
282
|
+
setIsOpen(false);
|
|
55
283
|
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
};
|
|
284
|
+
},
|
|
285
|
+
[multiple, value, onChange],
|
|
286
|
+
);
|
|
61
287
|
|
|
62
288
|
const handleRemove = (optionValue: any) => {
|
|
63
289
|
if (multiple) {
|
|
64
290
|
const currentValues = Array.isArray(value) ? value : [];
|
|
65
|
-
onChange(currentValues.filter(v => v !== optionValue));
|
|
291
|
+
onChange(currentValues.filter((v: any) => v !== optionValue));
|
|
66
292
|
} else {
|
|
67
293
|
onChange(null);
|
|
68
294
|
}
|
|
69
295
|
};
|
|
70
296
|
|
|
297
|
+
// Callback from RecordPickerDialog — caches selected records so that
|
|
298
|
+
// findOption can resolve display labels after the dialog closes.
|
|
299
|
+
const handlePickerSelectRecords = useCallback(
|
|
300
|
+
(records: any[]) => {
|
|
301
|
+
const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField));
|
|
302
|
+
setPickerResolvedRecords(mapped);
|
|
303
|
+
},
|
|
304
|
+
[displayField, idField, descriptionField],
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
// Keyboard handler for the search input — arrow keys + Enter
|
|
308
|
+
const handleSearchKeyDown = useCallback(
|
|
309
|
+
(e: React.KeyboardEvent) => {
|
|
310
|
+
if (e.key === 'ArrowDown') {
|
|
311
|
+
e.preventDefault();
|
|
312
|
+
setActiveIndex(prev =>
|
|
313
|
+
prev < filteredOptions.length - 1 ? prev + 1 : prev,
|
|
314
|
+
);
|
|
315
|
+
} else if (e.key === 'ArrowUp') {
|
|
316
|
+
e.preventDefault();
|
|
317
|
+
setActiveIndex(prev => (prev > 0 ? prev - 1 : 0));
|
|
318
|
+
} else if (e.key === 'Enter') {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
if (activeIndex >= 0 && activeIndex < filteredOptions.length) {
|
|
321
|
+
handleSelect(filteredOptions[activeIndex]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
},
|
|
325
|
+
[filteredOptions, activeIndex, handleSelect],
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Scroll active item into view
|
|
329
|
+
useEffect(() => {
|
|
330
|
+
if (activeIndex >= 0 && listRef.current) {
|
|
331
|
+
const el = listRef.current.querySelector(`[data-lookup-index="${activeIndex}"]`);
|
|
332
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
333
|
+
}
|
|
334
|
+
}, [activeIndex]);
|
|
335
|
+
|
|
71
336
|
if (readonly) {
|
|
72
337
|
if (!selectedOptions.length) {
|
|
73
338
|
return <span className="text-sm">-</span>;
|
|
@@ -117,12 +382,13 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
117
382
|
</div>
|
|
118
383
|
)}
|
|
119
384
|
|
|
120
|
-
{/*
|
|
121
|
-
<
|
|
122
|
-
|
|
385
|
+
{/* Level 1: Quick-select Popover (inline typeahead) */}
|
|
386
|
+
<div className="flex items-center gap-1.5">
|
|
387
|
+
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
388
|
+
<PopoverTrigger asChild>
|
|
123
389
|
<Button
|
|
124
390
|
variant="outline"
|
|
125
|
-
className="w-
|
|
391
|
+
className="min-w-0 flex-1 justify-start text-left font-normal"
|
|
126
392
|
type="button"
|
|
127
393
|
>
|
|
128
394
|
<Search className="mr-2 size-4" />
|
|
@@ -131,56 +397,195 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
131
397
|
: multiple ? `${selectedOptions.length} selected` : 'Change selection'
|
|
132
398
|
}
|
|
133
399
|
</Button>
|
|
134
|
-
</
|
|
135
|
-
<
|
|
136
|
-
<DialogHeader>
|
|
137
|
-
<DialogTitle>
|
|
138
|
-
{lookupField?.label || 'Select'} {multiple && '(multiple)'}
|
|
139
|
-
</DialogTitle>
|
|
140
|
-
</DialogHeader>
|
|
141
|
-
|
|
400
|
+
</PopoverTrigger>
|
|
401
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
142
402
|
{/* Search input */}
|
|
143
|
-
<div className="
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
403
|
+
<div className="p-2">
|
|
404
|
+
<div className="relative">
|
|
405
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
406
|
+
<Input
|
|
407
|
+
placeholder="Search..."
|
|
408
|
+
value={searchQuery}
|
|
409
|
+
onChange={(e) => handleSearchChange(e.target.value)}
|
|
410
|
+
onKeyDown={handleSearchKeyDown}
|
|
411
|
+
className="w-full pl-9 h-8 text-sm"
|
|
412
|
+
/>
|
|
413
|
+
{loading && (
|
|
414
|
+
<Loader2
|
|
415
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
|
|
416
|
+
data-testid="lookup-loading"
|
|
417
|
+
/>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
{/* Error state */}
|
|
423
|
+
{error && (
|
|
424
|
+
<div className="flex flex-col items-center gap-2 py-4 px-2" role="alert">
|
|
425
|
+
<AlertCircle className="size-5 text-destructive" />
|
|
426
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
427
|
+
<Button
|
|
428
|
+
variant="outline"
|
|
429
|
+
size="sm"
|
|
430
|
+
onClick={() => fetchLookupData(searchQuery || undefined)}
|
|
431
|
+
type="button"
|
|
432
|
+
>
|
|
433
|
+
Retry
|
|
434
|
+
</Button>
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
|
|
438
|
+
{/* Loading state (initial load only, not search refinement) */}
|
|
439
|
+
{loading && filteredOptions.length === 0 && !error && (
|
|
440
|
+
<div className="flex flex-col items-center gap-2 py-6" role="status" aria-live="polite">
|
|
441
|
+
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
442
|
+
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
443
|
+
</div>
|
|
444
|
+
)}
|
|
445
|
+
|
|
446
|
+
{/* Options list */}
|
|
447
|
+
{!error && !(loading && filteredOptions.length === 0) && (
|
|
448
|
+
<div ref={listRef} className="max-h-64 overflow-y-auto px-1 pb-1" role="listbox">
|
|
153
449
|
{filteredOptions.length === 0 ? (
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
450
|
+
<div className="py-4 text-center">
|
|
451
|
+
<p className="text-sm text-muted-foreground">
|
|
452
|
+
No options found
|
|
453
|
+
</p>
|
|
454
|
+
{/* Quick-create entry */}
|
|
455
|
+
{onCreateNew && (
|
|
456
|
+
<Button
|
|
457
|
+
variant="ghost"
|
|
458
|
+
size="sm"
|
|
459
|
+
className="mt-2 gap-1"
|
|
460
|
+
type="button"
|
|
461
|
+
onClick={() => {
|
|
462
|
+
onCreateNew(searchQuery);
|
|
463
|
+
setIsOpen(false);
|
|
464
|
+
}}
|
|
465
|
+
>
|
|
466
|
+
<Plus className="size-4" />
|
|
467
|
+
Create new
|
|
468
|
+
</Button>
|
|
469
|
+
)}
|
|
470
|
+
</div>
|
|
157
471
|
) : (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
472
|
+
<>
|
|
473
|
+
{filteredOptions.map((option, idx) => {
|
|
474
|
+
const isSelected = multiple
|
|
475
|
+
? (Array.isArray(value) ? value : []).includes(option.value)
|
|
476
|
+
: value === option.value;
|
|
477
|
+
const isActive = idx === activeIndex;
|
|
162
478
|
|
|
163
|
-
|
|
479
|
+
return (
|
|
480
|
+
<button
|
|
481
|
+
key={option.value}
|
|
482
|
+
data-lookup-index={idx}
|
|
483
|
+
role="option"
|
|
484
|
+
aria-selected={isSelected}
|
|
485
|
+
onClick={() => handleSelect(option)}
|
|
486
|
+
className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
|
|
487
|
+
isActive
|
|
488
|
+
? 'bg-accent text-accent-foreground'
|
|
489
|
+
: isSelected
|
|
490
|
+
? 'bg-accent/50 text-accent-foreground'
|
|
491
|
+
: ''
|
|
492
|
+
}`}
|
|
493
|
+
type="button"
|
|
494
|
+
>
|
|
495
|
+
<div className="min-w-0 flex-1">
|
|
496
|
+
<span className="block truncate">{option.label}</span>
|
|
497
|
+
{option.description && (
|
|
498
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
499
|
+
{option.description}
|
|
500
|
+
</span>
|
|
501
|
+
)}
|
|
502
|
+
</div>
|
|
503
|
+
{isSelected && (
|
|
504
|
+
<Badge variant="default" className="ml-2 shrink-0">Selected</Badge>
|
|
505
|
+
)}
|
|
506
|
+
</button>
|
|
507
|
+
);
|
|
508
|
+
})}
|
|
509
|
+
{/* Show total count when fetched from DataSource */}
|
|
510
|
+
{hasDataSource && totalCount > filteredOptions.length && (
|
|
511
|
+
<p className="text-xs text-muted-foreground text-center py-2">
|
|
512
|
+
Showing {filteredOptions.length} of {totalCount} results.
|
|
513
|
+
</p>
|
|
514
|
+
)}
|
|
515
|
+
{/* "Show All Results" button — opens the full Record Picker (Level 2) */}
|
|
516
|
+
{hasDataSource && totalCount > filteredOptions.length && (
|
|
517
|
+
<button
|
|
518
|
+
type="button"
|
|
519
|
+
className="w-full text-center px-3 py-2 rounded-md text-sm font-medium text-primary hover:bg-accent flex items-center justify-center gap-1.5"
|
|
520
|
+
onClick={() => {
|
|
521
|
+
setIsOpen(false);
|
|
522
|
+
setIsPickerOpen(true);
|
|
523
|
+
}}
|
|
524
|
+
data-testid="show-all-results"
|
|
525
|
+
>
|
|
526
|
+
<TableProperties className="size-3.5" />
|
|
527
|
+
Show All Results ({totalCount})
|
|
528
|
+
</button>
|
|
529
|
+
)}
|
|
530
|
+
{/* Quick-create entry (below results) */}
|
|
531
|
+
{onCreateNew && (
|
|
164
532
|
<button
|
|
165
|
-
key={option.value}
|
|
166
|
-
onClick={() => handleSelect(option)}
|
|
167
|
-
className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-gray-100 flex items-center justify-between ${
|
|
168
|
-
isSelected ? 'bg-blue-50 text-blue-700' : ''
|
|
169
|
-
}`}
|
|
170
533
|
type="button"
|
|
534
|
+
className="w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center gap-1.5 text-muted-foreground"
|
|
535
|
+
onClick={() => {
|
|
536
|
+
onCreateNew(searchQuery);
|
|
537
|
+
setIsOpen(false);
|
|
538
|
+
}}
|
|
171
539
|
>
|
|
172
|
-
<
|
|
173
|
-
{
|
|
174
|
-
<Badge variant="default" className="ml-2">Selected</Badge>
|
|
175
|
-
)}
|
|
540
|
+
<Plus className="size-3.5" />
|
|
541
|
+
Create new{searchQuery ? ` "${searchQuery}"` : ''}
|
|
176
542
|
</button>
|
|
177
|
-
)
|
|
178
|
-
|
|
543
|
+
)}
|
|
544
|
+
</>
|
|
179
545
|
)}
|
|
180
546
|
</div>
|
|
181
|
-
|
|
182
|
-
</
|
|
183
|
-
</
|
|
547
|
+
)}
|
|
548
|
+
</PopoverContent>
|
|
549
|
+
</Popover>
|
|
550
|
+
|
|
551
|
+
{/* "Browse All" button — always visible when DataSource is available */}
|
|
552
|
+
{hasDataSource && (
|
|
553
|
+
<Button
|
|
554
|
+
variant="outline"
|
|
555
|
+
size="icon"
|
|
556
|
+
className="shrink-0"
|
|
557
|
+
type="button"
|
|
558
|
+
onClick={() => setIsPickerOpen(true)}
|
|
559
|
+
aria-label="Browse all records"
|
|
560
|
+
title="Browse all records"
|
|
561
|
+
data-testid="browse-all-records"
|
|
562
|
+
>
|
|
563
|
+
<TableProperties className="size-4" />
|
|
564
|
+
</Button>
|
|
565
|
+
)}
|
|
566
|
+
</div>
|
|
567
|
+
|
|
568
|
+
{/* Level 2: Full Record Picker Dialog */}
|
|
569
|
+
{hasDataSource && dataSource && referenceTo && (
|
|
570
|
+
<RecordPickerDialog
|
|
571
|
+
open={isPickerOpen}
|
|
572
|
+
onOpenChange={setIsPickerOpen}
|
|
573
|
+
title={lookupField?.label || 'Select'}
|
|
574
|
+
multiple={multiple}
|
|
575
|
+
dataSource={dataSource}
|
|
576
|
+
objectName={referenceTo}
|
|
577
|
+
columns={lookupColumns}
|
|
578
|
+
displayField={displayField}
|
|
579
|
+
idField={idField}
|
|
580
|
+
pageSize={lookupPageSize}
|
|
581
|
+
value={value}
|
|
582
|
+
onSelect={onChange}
|
|
583
|
+
onSelectRecords={handlePickerSelectRecords}
|
|
584
|
+
lookupFilters={lookupFilters}
|
|
585
|
+
cellRenderer={getCellRendererResolver()}
|
|
586
|
+
filterColumns={filterColumns}
|
|
587
|
+
/>
|
|
588
|
+
)}
|
|
184
589
|
</div>
|
|
185
590
|
);
|
|
186
591
|
}
|