@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.
@@ -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
- * Lookup field for selecting related records
23
- * Supports single and multi-select with search
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
- // Filter options based on search
35
- const filteredOptions = options.filter(opt =>
36
- opt.label.toLowerCase().includes(searchQuery.toLowerCase())
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(v =>
42
- options.find(opt => opt.value === v)
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 = (option: LookupOption) => {
47
- if (multiple) {
48
- const currentValues = Array.isArray(value) ? value : [];
49
- const isSelected = currentValues.includes(option.value);
50
-
51
- if (isSelected) {
52
- onChange(currentValues.filter(v => v !== option.value));
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([...currentValues, option.value]);
236
+ onChange(option.value);
237
+ setIsOpen(false);
55
238
  }
56
- } else {
57
- onChange(option.value);
58
- setIsOpen(false);
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
- <Input
145
- placeholder="Search..."
146
- value={searchQuery}
147
- onChange={(e) => setSearchQuery(e.target.value)}
148
- className="w-full"
149
- />
150
-
151
- {/* Options list */}
152
- <div className="max-h-64 overflow-y-auto space-y-1">
153
- {filteredOptions.length === 0 ? (
154
- <p className="text-sm text-gray-500 text-center py-4">
155
- No options found
156
- </p>
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>