@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
|
@@ -1,17 +1,19 @@
|
|
|
1
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, Loader2, AlertCircle, Plus } from 'lucide-react';
|
|
10
|
+
import { Search, X, Loader2, AlertCircle, Plus, TableProperties } from 'lucide-react';
|
|
13
11
|
import { FieldWidgetProps } from './types';
|
|
14
|
-
import type { DataSource, QueryParams } from '@object-ui/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';
|
|
15
17
|
|
|
16
18
|
export interface LookupOption {
|
|
17
19
|
value: string | number;
|
|
@@ -20,25 +22,14 @@ export interface LookupOption {
|
|
|
20
22
|
[key: string]: any;
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
/**
|
|
25
|
+
/** Page size for the quick-select popover typeahead */
|
|
24
26
|
const LOOKUP_PAGE_SIZE = 50;
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* a hard dependency on @object-ui/react (which would create a cycle).
|
|
29
|
+
* SchemaRendererContext is created by @object-ui/react.
|
|
30
|
+
* Using a static import to be compatible with Next.js Turbopack SSR.
|
|
30
31
|
*/
|
|
31
|
-
const
|
|
32
|
-
let SchemaRendererContext: React.Context<any> = FallbackContext;
|
|
33
|
-
try {
|
|
34
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
|
-
const mod = require('@object-ui/react');
|
|
36
|
-
if (mod.SchemaRendererContext) {
|
|
37
|
-
SchemaRendererContext = mod.SchemaRendererContext;
|
|
38
|
-
}
|
|
39
|
-
} catch {
|
|
40
|
-
// @object-ui/react not available — dataSource must be passed via props
|
|
41
|
-
}
|
|
32
|
+
const SchemaRendererContext: React.Context<any> = ImportedSchemaRendererContext;
|
|
42
33
|
|
|
43
34
|
/**
|
|
44
35
|
* Map a raw record to a LookupOption using a display field and an id field.
|
|
@@ -55,6 +46,27 @@ function recordToOption(
|
|
|
55
46
|
return { value: val, label: String(label), description, ...record };
|
|
56
47
|
}
|
|
57
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
|
+
|
|
58
70
|
/**
|
|
59
71
|
* Lookup field for selecting related records.
|
|
60
72
|
* Supports single and multi-select with search.
|
|
@@ -75,6 +87,11 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
75
87
|
const [totalCount, setTotalCount] = useState(0);
|
|
76
88
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
77
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
|
+
|
|
78
95
|
// Arrow-key active index (-1 = none)
|
|
79
96
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
80
97
|
const listRef = useRef<HTMLDivElement>(null);
|
|
@@ -99,6 +116,30 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
99
116
|
// ObjectStack convention uses `reference`; types define `reference_to` — support both
|
|
100
117
|
const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
|
|
101
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
|
+
|
|
102
143
|
// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
|
|
103
144
|
const ctx = useContext(SchemaRendererContext);
|
|
104
145
|
const contextDataSource = ctx?.dataSource ?? null;
|
|
@@ -111,6 +152,9 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
111
152
|
const onCreateNew: ((searchQuery: string) => void) | undefined =
|
|
112
153
|
(props as any).onCreateNew ?? lookupField?.onCreateNew;
|
|
113
154
|
|
|
155
|
+
// State for the full Record Picker dialog (Level 2)
|
|
156
|
+
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
|
157
|
+
|
|
114
158
|
// Determine which options to display
|
|
115
159
|
const allOptions = hasDataSource ? fetchedOptions : staticOptions;
|
|
116
160
|
|
|
@@ -206,15 +250,16 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
206
250
|
};
|
|
207
251
|
}, []);
|
|
208
252
|
|
|
209
|
-
// Get selected option(s) — check
|
|
253
|
+
// Get selected option(s) — check static, fetched, and picker-resolved options
|
|
210
254
|
const findOption = useCallback(
|
|
211
255
|
(v: any): LookupOption | undefined => {
|
|
212
256
|
return (
|
|
213
257
|
staticOptions.find(opt => opt.value === v) ??
|
|
214
|
-
fetchedOptions.find(opt => opt.value === v)
|
|
258
|
+
fetchedOptions.find(opt => opt.value === v) ??
|
|
259
|
+
pickerResolvedRecords.find(opt => opt.value === v)
|
|
215
260
|
);
|
|
216
261
|
},
|
|
217
|
-
[staticOptions, fetchedOptions],
|
|
262
|
+
[staticOptions, fetchedOptions, pickerResolvedRecords],
|
|
218
263
|
);
|
|
219
264
|
|
|
220
265
|
const selectedOptions = multiple
|
|
@@ -249,6 +294,16 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
249
294
|
}
|
|
250
295
|
};
|
|
251
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
|
+
|
|
252
307
|
// Keyboard handler for the search input — arrow keys + Enter
|
|
253
308
|
const handleSearchKeyDown = useCallback(
|
|
254
309
|
(e: React.KeyboardEvent) => {
|
|
@@ -327,12 +382,13 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
327
382
|
</div>
|
|
328
383
|
)}
|
|
329
384
|
|
|
330
|
-
{/*
|
|
331
|
-
<
|
|
332
|
-
|
|
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>
|
|
333
389
|
<Button
|
|
334
390
|
variant="outline"
|
|
335
|
-
className="w-
|
|
391
|
+
className="min-w-0 flex-1 justify-start text-left font-normal"
|
|
336
392
|
type="button"
|
|
337
393
|
>
|
|
338
394
|
<Search className="mr-2 size-4" />
|
|
@@ -341,16 +397,10 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
341
397
|
: multiple ? `${selectedOptions.length} selected` : 'Change selection'
|
|
342
398
|
}
|
|
343
399
|
</Button>
|
|
344
|
-
</
|
|
345
|
-
<
|
|
346
|
-
<DialogHeader>
|
|
347
|
-
<DialogTitle>
|
|
348
|
-
{lookupField?.label || 'Select'} {multiple && '(multiple)'}
|
|
349
|
-
</DialogTitle>
|
|
350
|
-
</DialogHeader>
|
|
351
|
-
|
|
400
|
+
</PopoverTrigger>
|
|
401
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
352
402
|
{/* Search input */}
|
|
353
|
-
<div className="
|
|
403
|
+
<div className="p-2">
|
|
354
404
|
<div className="relative">
|
|
355
405
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
356
406
|
<Input
|
|
@@ -358,7 +408,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
358
408
|
value={searchQuery}
|
|
359
409
|
onChange={(e) => handleSearchChange(e.target.value)}
|
|
360
410
|
onKeyDown={handleSearchKeyDown}
|
|
361
|
-
className="w-full pl-9"
|
|
411
|
+
className="w-full pl-9 h-8 text-sm"
|
|
362
412
|
/>
|
|
363
413
|
{loading && (
|
|
364
414
|
<Loader2
|
|
@@ -367,121 +417,175 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
367
417
|
/>
|
|
368
418
|
)}
|
|
369
419
|
</div>
|
|
420
|
+
</div>
|
|
370
421
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
<button
|
|
430
|
-
key={option.value}
|
|
431
|
-
data-lookup-index={idx}
|
|
432
|
-
role="option"
|
|
433
|
-
aria-selected={isSelected}
|
|
434
|
-
onClick={() => handleSelect(option)}
|
|
435
|
-
className={`w-full text-left px-3 py-2 rounded-md text-sm hover:bg-accent flex items-center justify-between ${
|
|
436
|
-
isActive
|
|
437
|
-
? 'bg-accent text-accent-foreground'
|
|
438
|
-
: isSelected
|
|
439
|
-
? 'bg-accent/50 text-accent-foreground'
|
|
440
|
-
: ''
|
|
441
|
-
}`}
|
|
442
|
-
type="button"
|
|
443
|
-
>
|
|
444
|
-
<div className="min-w-0 flex-1">
|
|
445
|
-
<span className="block truncate">{option.label}</span>
|
|
446
|
-
{option.description && (
|
|
447
|
-
<span className="block truncate text-xs text-muted-foreground">
|
|
448
|
-
{option.description}
|
|
449
|
-
</span>
|
|
450
|
-
)}
|
|
451
|
-
</div>
|
|
452
|
-
{isSelected && (
|
|
453
|
-
<Badge variant="default" className="ml-2 shrink-0">Selected</Badge>
|
|
454
|
-
)}
|
|
455
|
-
</button>
|
|
456
|
-
);
|
|
457
|
-
})}
|
|
458
|
-
{/* Show total count when fetched from DataSource */}
|
|
459
|
-
{hasDataSource && totalCount > filteredOptions.length && (
|
|
460
|
-
<p className="text-xs text-muted-foreground text-center py-2">
|
|
461
|
-
Showing {filteredOptions.length} of {totalCount} results. Refine your search to find more.
|
|
462
|
-
</p>
|
|
463
|
-
)}
|
|
464
|
-
{/* Quick-create entry (below results) */}
|
|
465
|
-
{onCreateNew && (
|
|
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">
|
|
449
|
+
{filteredOptions.length === 0 ? (
|
|
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>
|
|
471
|
+
) : (
|
|
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;
|
|
478
|
+
|
|
479
|
+
return (
|
|
466
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
|
+
}`}
|
|
467
493
|
type="button"
|
|
468
|
-
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"
|
|
469
|
-
onClick={() => {
|
|
470
|
-
onCreateNew(searchQuery);
|
|
471
|
-
setIsOpen(false);
|
|
472
|
-
}}
|
|
473
494
|
>
|
|
474
|
-
<
|
|
475
|
-
|
|
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
|
+
)}
|
|
476
506
|
</button>
|
|
477
|
-
)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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 && (
|
|
532
|
+
<button
|
|
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
|
+
}}
|
|
539
|
+
>
|
|
540
|
+
<Plus className="size-3.5" />
|
|
541
|
+
Create new{searchQuery ? ` "${searchQuery}"` : ''}
|
|
542
|
+
</button>
|
|
543
|
+
)}
|
|
544
|
+
</>
|
|
545
|
+
)}
|
|
546
|
+
</div>
|
|
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
|
+
)}
|
|
485
589
|
</div>
|
|
486
590
|
);
|
|
487
591
|
}
|