@object-ui/fields 3.1.1 → 3.1.3
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/fields/src/index.d.ts +7 -0
- package/dist/fields/src/widgets/LookupField.d.ts +13 -2
- package/dist/index.cjs +4 -4
- package/dist/index.js +1153 -985
- package/package.json +5 -5
- package/src/__tests__/cell-renderers.test.tsx +133 -4
- package/src/complex-widgets.test.tsx +426 -2
- package/src/index.tsx +70 -22
- package/src/widgets/LookupField.tsx +366 -65
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Button,
|
|
4
4
|
Dialog,
|
|
@@ -9,65 +9,275 @@ import {
|
|
|
9
9
|
Input,
|
|
10
10
|
Badge
|
|
11
11
|
} from '@object-ui/components';
|
|
12
|
-
import { Search, X } from 'lucide-react';
|
|
12
|
+
import { Search, X, Loader2, AlertCircle, Plus } from 'lucide-react';
|
|
13
13
|
import { FieldWidgetProps } from './types';
|
|
14
|
+
import type { DataSource, QueryParams } from '@object-ui/types';
|
|
14
15
|
|
|
15
|
-
interface LookupOption {
|
|
16
|
+
export interface LookupOption {
|
|
16
17
|
value: string | number;
|
|
17
18
|
label: string;
|
|
19
|
+
description?: string;
|
|
18
20
|
[key: string]: any;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/** Default page size for lookup data fetching */
|
|
24
|
+
const LOOKUP_PAGE_SIZE = 50;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve SchemaRendererContext from @object-ui/react at runtime.
|
|
28
|
+
* Uses the same dynamic-require fallback that plugin-view uses to avoid
|
|
29
|
+
* a hard dependency on @object-ui/react (which would create a cycle).
|
|
30
|
+
*/
|
|
31
|
+
const FallbackContext = React.createContext<any>(null);
|
|
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
|
+
}
|
|
42
|
+
|
|
21
43
|
/**
|
|
22
|
-
*
|
|
23
|
-
|
|
44
|
+
* Map a raw record to a LookupOption using a display field and an id field.
|
|
45
|
+
*/
|
|
46
|
+
function recordToOption(
|
|
47
|
+
record: any,
|
|
48
|
+
displayField: string,
|
|
49
|
+
idField: string,
|
|
50
|
+
descriptionField?: string,
|
|
51
|
+
): LookupOption {
|
|
52
|
+
const val = record[idField] ?? record.id ?? record._id;
|
|
53
|
+
const label = record[displayField] ?? record.label ?? record.name ?? String(val);
|
|
54
|
+
const description = descriptionField ? record[descriptionField] : undefined;
|
|
55
|
+
return { value: val, label: String(label), description, ...record };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Lookup field for selecting related records.
|
|
60
|
+
* Supports single and multi-select with search.
|
|
61
|
+
*
|
|
62
|
+
* When a `dataSource` is provided (either via props, via `field.dataSource`,
|
|
63
|
+
* or via SchemaRendererContext), the dialog will dynamically load records
|
|
64
|
+
* from the referenced object using `DataSource.find()`.
|
|
65
|
+
* Falls back to static `options` when no DataSource is available.
|
|
24
66
|
*/
|
|
25
67
|
export function LookupField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
26
68
|
const [isOpen, setIsOpen] = useState(false);
|
|
27
69
|
const [searchQuery, setSearchQuery] = useState('');
|
|
28
70
|
|
|
71
|
+
// Dynamic data loading state
|
|
72
|
+
const [fetchedOptions, setFetchedOptions] = useState<LookupOption[]>([]);
|
|
73
|
+
const [loading, setLoading] = useState(false);
|
|
74
|
+
const [error, setError] = useState<string | null>(null);
|
|
75
|
+
const [totalCount, setTotalCount] = useState(0);
|
|
76
|
+
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
77
|
+
|
|
78
|
+
// Arrow-key active index (-1 = none)
|
|
79
|
+
const [activeIndex, setActiveIndex] = useState(-1);
|
|
80
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
81
|
+
|
|
29
82
|
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
83
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
84
|
+
// When rendered via createFieldRenderer wrapper the actual objectSchema field
|
|
85
|
+
// metadata (reference_to, display_field, etc.) lives at lookupField.field.
|
|
86
|
+
// Unwrap it so lookup-specific properties resolve correctly.
|
|
87
|
+
// ObjectStack convention uses `reference` while the types use `reference_to`,
|
|
88
|
+
// so we check for both property names.
|
|
89
|
+
const innerField = lookupField?.field;
|
|
90
|
+
const fieldMeta = (innerField && typeof innerField === 'object' && ('reference_to' in innerField || 'reference' in innerField || 'type' in innerField))
|
|
91
|
+
? innerField
|
|
92
|
+
: lookupField;
|
|
93
|
+
|
|
94
|
+
const staticOptions: LookupOption[] = fieldMeta?.options || [];
|
|
95
|
+
const multiple = fieldMeta?.multiple || false;
|
|
96
|
+
const displayField = fieldMeta?.display_field || fieldMeta?.reference_field || 'name';
|
|
97
|
+
const descriptionField: string | undefined = fieldMeta?.description_field;
|
|
98
|
+
const idField = fieldMeta?.id_field || 'id';
|
|
99
|
+
// ObjectStack convention uses `reference`; types define `reference_to` — support both
|
|
100
|
+
const referenceTo: string | undefined = fieldMeta?.reference_to || fieldMeta?.reference;
|
|
101
|
+
|
|
102
|
+
// Resolve DataSource: explicit prop > field-level > wrapper field > SchemaRendererContext > none
|
|
103
|
+
const ctx = useContext(SchemaRendererContext);
|
|
104
|
+
const contextDataSource = ctx?.dataSource ?? null;
|
|
105
|
+
const dataSource: DataSource | null =
|
|
106
|
+
(props as any).dataSource ?? lookupField?.dataSource ?? fieldMeta?.dataSource ?? contextDataSource;
|
|
107
|
+
|
|
108
|
+
const hasDataSource = dataSource != null && typeof dataSource.find === 'function' && !!referenceTo;
|
|
109
|
+
|
|
110
|
+
// Optional create-new callback
|
|
111
|
+
const onCreateNew: ((searchQuery: string) => void) | undefined =
|
|
112
|
+
(props as any).onCreateNew ?? lookupField?.onCreateNew;
|
|
113
|
+
|
|
114
|
+
// Determine which options to display
|
|
115
|
+
const allOptions = hasDataSource ? fetchedOptions : staticOptions;
|
|
116
|
+
|
|
117
|
+
// For static options, filter locally based on search
|
|
118
|
+
const filteredOptions = useMemo(() => {
|
|
119
|
+
if (hasDataSource) return allOptions;
|
|
120
|
+
if (!searchQuery) return allOptions;
|
|
121
|
+
const q = searchQuery.toLowerCase();
|
|
122
|
+
return allOptions.filter(opt =>
|
|
123
|
+
opt.label.toLowerCase().includes(q) ||
|
|
124
|
+
(opt.description && opt.description.toLowerCase().includes(q))
|
|
125
|
+
);
|
|
126
|
+
}, [hasDataSource, allOptions, searchQuery]);
|
|
127
|
+
|
|
128
|
+
// Reset active index when options change
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
setActiveIndex(-1);
|
|
131
|
+
}, [filteredOptions.length]);
|
|
132
|
+
|
|
133
|
+
// Fetch data from DataSource
|
|
134
|
+
const fetchLookupData = useCallback(
|
|
135
|
+
async (search?: string) => {
|
|
136
|
+
if (!dataSource || !referenceTo) return;
|
|
137
|
+
|
|
138
|
+
setLoading(true);
|
|
139
|
+
setError(null);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const params: QueryParams = {
|
|
143
|
+
$top: LOOKUP_PAGE_SIZE,
|
|
144
|
+
};
|
|
145
|
+
if (search && search.trim()) {
|
|
146
|
+
params.$search = search.trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const result = await dataSource.find(referenceTo, params);
|
|
150
|
+
const records: any[] = result?.data ?? result ?? [];
|
|
151
|
+
const mapped = records.map(r => recordToOption(r, displayField, idField, descriptionField));
|
|
152
|
+
|
|
153
|
+
setFetchedOptions(mapped);
|
|
154
|
+
setTotalCount(result?.total ?? records.length);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
157
|
+
setError(msg);
|
|
158
|
+
setFetchedOptions([]);
|
|
159
|
+
} finally {
|
|
160
|
+
setLoading(false);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
[dataSource, referenceTo, displayField, idField, descriptionField],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Fetch data when dialog opens.
|
|
167
|
+
// We intentionally depend only on `isOpen` so the effect fires once per
|
|
168
|
+
// open/close transition. `fetchLookupData` is stable-enough via its own
|
|
169
|
+
// useCallback deps; including it here would cause spurious re-fetches.
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (isOpen && hasDataSource) {
|
|
172
|
+
fetchLookupData(searchQuery || undefined);
|
|
173
|
+
}
|
|
174
|
+
// Clean up fetched data when dialog closes
|
|
175
|
+
if (!isOpen) {
|
|
176
|
+
setSearchQuery('');
|
|
177
|
+
setError(null);
|
|
178
|
+
setActiveIndex(-1);
|
|
179
|
+
}
|
|
180
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
181
|
+
}, [isOpen]);
|
|
182
|
+
|
|
183
|
+
// Debounced search
|
|
184
|
+
const handleSearchChange = useCallback(
|
|
185
|
+
(query: string) => {
|
|
186
|
+
setSearchQuery(query);
|
|
187
|
+
|
|
188
|
+
if (!hasDataSource) return;
|
|
189
|
+
|
|
190
|
+
if (debounceTimer.current) {
|
|
191
|
+
clearTimeout(debounceTimer.current);
|
|
192
|
+
}
|
|
193
|
+
debounceTimer.current = setTimeout(() => {
|
|
194
|
+
fetchLookupData(query || undefined);
|
|
195
|
+
}, 300);
|
|
196
|
+
},
|
|
197
|
+
[hasDataSource, fetchLookupData],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Clean up debounce timer
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
return () => {
|
|
203
|
+
if (debounceTimer.current) {
|
|
204
|
+
clearTimeout(debounceTimer.current);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
209
|
+
// Get selected option(s) — check both static and fetched options
|
|
210
|
+
const findOption = useCallback(
|
|
211
|
+
(v: any): LookupOption | undefined => {
|
|
212
|
+
return (
|
|
213
|
+
staticOptions.find(opt => opt.value === v) ??
|
|
214
|
+
fetchedOptions.find(opt => opt.value === v)
|
|
215
|
+
);
|
|
216
|
+
},
|
|
217
|
+
[staticOptions, fetchedOptions],
|
|
37
218
|
);
|
|
38
219
|
|
|
39
|
-
// Get selected option(s)
|
|
40
220
|
const selectedOptions = multiple
|
|
41
|
-
? (Array.isArray(value) ? value : []).map(
|
|
42
|
-
|
|
43
|
-
).filter(Boolean)
|
|
44
|
-
: value ? [options.find(opt => opt.value === value)].filter(Boolean) : [];
|
|
221
|
+
? (Array.isArray(value) ? value : []).map(findOption).filter(Boolean)
|
|
222
|
+
: value ? [findOption(value)].filter(Boolean) : [];
|
|
45
223
|
|
|
46
|
-
const handleSelect = (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
224
|
+
const handleSelect = useCallback(
|
|
225
|
+
(option: LookupOption) => {
|
|
226
|
+
if (multiple) {
|
|
227
|
+
const currentValues = Array.isArray(value) ? value : [];
|
|
228
|
+
const isSelected = currentValues.includes(option.value);
|
|
229
|
+
|
|
230
|
+
if (isSelected) {
|
|
231
|
+
onChange(currentValues.filter((v: any) => v !== option.value));
|
|
232
|
+
} else {
|
|
233
|
+
onChange([...currentValues, option.value]);
|
|
234
|
+
}
|
|
53
235
|
} else {
|
|
54
|
-
onChange(
|
|
236
|
+
onChange(option.value);
|
|
237
|
+
setIsOpen(false);
|
|
55
238
|
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
};
|
|
239
|
+
},
|
|
240
|
+
[multiple, value, onChange],
|
|
241
|
+
);
|
|
61
242
|
|
|
62
243
|
const handleRemove = (optionValue: any) => {
|
|
63
244
|
if (multiple) {
|
|
64
245
|
const currentValues = Array.isArray(value) ? value : [];
|
|
65
|
-
onChange(currentValues.filter(v => v !== optionValue));
|
|
246
|
+
onChange(currentValues.filter((v: any) => v !== optionValue));
|
|
66
247
|
} else {
|
|
67
248
|
onChange(null);
|
|
68
249
|
}
|
|
69
250
|
};
|
|
70
251
|
|
|
252
|
+
// Keyboard handler for the search input — arrow keys + Enter
|
|
253
|
+
const handleSearchKeyDown = useCallback(
|
|
254
|
+
(e: React.KeyboardEvent) => {
|
|
255
|
+
if (e.key === 'ArrowDown') {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
setActiveIndex(prev =>
|
|
258
|
+
prev < filteredOptions.length - 1 ? prev + 1 : prev,
|
|
259
|
+
);
|
|
260
|
+
} else if (e.key === 'ArrowUp') {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
setActiveIndex(prev => (prev > 0 ? prev - 1 : 0));
|
|
263
|
+
} else if (e.key === 'Enter') {
|
|
264
|
+
e.preventDefault();
|
|
265
|
+
if (activeIndex >= 0 && activeIndex < filteredOptions.length) {
|
|
266
|
+
handleSelect(filteredOptions[activeIndex]);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
[filteredOptions, activeIndex, handleSelect],
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
// Scroll active item into view
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
if (activeIndex >= 0 && listRef.current) {
|
|
276
|
+
const el = listRef.current.querySelector(`[data-lookup-index="${activeIndex}"]`);
|
|
277
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
278
|
+
}
|
|
279
|
+
}, [activeIndex]);
|
|
280
|
+
|
|
71
281
|
if (readonly) {
|
|
72
282
|
if (!selectedOptions.length) {
|
|
73
283
|
return <span className="text-sm">-</span>;
|
|
@@ -141,43 +351,134 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
|
|
|
141
351
|
|
|
142
352
|
{/* Search input */}
|
|
143
353
|
<div className="space-y-4">
|
|
144
|
-
<
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
{
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
filteredOptions.map((option) => {
|
|
159
|
-
const isSelected = multiple
|
|
160
|
-
? (Array.isArray(value) ? value : []).includes(option.value)
|
|
161
|
-
: value === option.value;
|
|
162
|
-
|
|
163
|
-
return (
|
|
164
|
-
<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
|
-
type="button"
|
|
171
|
-
>
|
|
172
|
-
<span>{option.label}</span>
|
|
173
|
-
{isSelected && (
|
|
174
|
-
<Badge variant="default" className="ml-2">Selected</Badge>
|
|
175
|
-
)}
|
|
176
|
-
</button>
|
|
177
|
-
);
|
|
178
|
-
})
|
|
354
|
+
<div className="relative">
|
|
355
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
356
|
+
<Input
|
|
357
|
+
placeholder="Search..."
|
|
358
|
+
value={searchQuery}
|
|
359
|
+
onChange={(e) => handleSearchChange(e.target.value)}
|
|
360
|
+
onKeyDown={handleSearchKeyDown}
|
|
361
|
+
className="w-full pl-9"
|
|
362
|
+
/>
|
|
363
|
+
{loading && (
|
|
364
|
+
<Loader2
|
|
365
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 animate-spin text-muted-foreground"
|
|
366
|
+
data-testid="lookup-loading"
|
|
367
|
+
/>
|
|
179
368
|
)}
|
|
180
369
|
</div>
|
|
370
|
+
|
|
371
|
+
{/* Error state */}
|
|
372
|
+
{error && (
|
|
373
|
+
<div className="flex flex-col items-center gap-2 py-4" role="alert">
|
|
374
|
+
<AlertCircle className="size-5 text-destructive" />
|
|
375
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
376
|
+
<Button
|
|
377
|
+
variant="outline"
|
|
378
|
+
size="sm"
|
|
379
|
+
onClick={() => fetchLookupData(searchQuery || undefined)}
|
|
380
|
+
type="button"
|
|
381
|
+
>
|
|
382
|
+
Retry
|
|
383
|
+
</Button>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
|
|
387
|
+
{/* Loading state (initial load only, not search refinement) */}
|
|
388
|
+
{loading && filteredOptions.length === 0 && !error && (
|
|
389
|
+
<div className="flex flex-col items-center gap-2 py-6" role="status" aria-live="polite">
|
|
390
|
+
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
391
|
+
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
392
|
+
</div>
|
|
393
|
+
)}
|
|
394
|
+
|
|
395
|
+
{/* Options list */}
|
|
396
|
+
{!error && !(loading && filteredOptions.length === 0) && (
|
|
397
|
+
<div ref={listRef} className="max-h-64 overflow-y-auto space-y-1" role="listbox">
|
|
398
|
+
{filteredOptions.length === 0 ? (
|
|
399
|
+
<div className="py-4 text-center">
|
|
400
|
+
<p className="text-sm text-muted-foreground">
|
|
401
|
+
No options found
|
|
402
|
+
</p>
|
|
403
|
+
{/* Quick-create entry */}
|
|
404
|
+
{onCreateNew && (
|
|
405
|
+
<Button
|
|
406
|
+
variant="ghost"
|
|
407
|
+
size="sm"
|
|
408
|
+
className="mt-2 gap-1"
|
|
409
|
+
type="button"
|
|
410
|
+
onClick={() => {
|
|
411
|
+
onCreateNew(searchQuery);
|
|
412
|
+
setIsOpen(false);
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
<Plus className="size-4" />
|
|
416
|
+
Create new
|
|
417
|
+
</Button>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
) : (
|
|
421
|
+
<>
|
|
422
|
+
{filteredOptions.map((option, idx) => {
|
|
423
|
+
const isSelected = multiple
|
|
424
|
+
? (Array.isArray(value) ? value : []).includes(option.value)
|
|
425
|
+
: value === option.value;
|
|
426
|
+
const isActive = idx === activeIndex;
|
|
427
|
+
|
|
428
|
+
return (
|
|
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 && (
|
|
466
|
+
<button
|
|
467
|
+
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
|
+
>
|
|
474
|
+
<Plus className="size-3.5" />
|
|
475
|
+
Create new{searchQuery ? ` "${searchQuery}"` : ''}
|
|
476
|
+
</button>
|
|
477
|
+
)}
|
|
478
|
+
</>
|
|
479
|
+
)}
|
|
480
|
+
</div>
|
|
481
|
+
)}
|
|
181
482
|
</div>
|
|
182
483
|
</DialogContent>
|
|
183
484
|
</Dialog>
|