|
| 1 | +import React, { useState, useEffect } from "react" |
| 2 | +import { Check, ChevronsUpDown, Loader2, X } from "lucide-react" |
| 3 | + |
| 4 | +import { cn } from "@/lib/utils" |
| 5 | +import { Button } from "../ui/button" |
| 6 | +import { |
| 7 | + Command, |
| 8 | + CommandEmpty, |
| 9 | + CommandGroup, |
| 10 | + CommandInput, |
| 11 | + CommandItem, |
| 12 | +} from "../ui/command" |
| 13 | +import { |
| 14 | + Popover, |
| 15 | + PopoverContent, |
| 16 | + PopoverTrigger, |
| 17 | +} from "../ui/popover" |
| 18 | +import { Label } from "../ui/label" |
| 19 | +import { LookupFieldProps } from "./types" |
| 20 | + |
| 21 | +function useDebounce<T>(value: T, delay: number): T { |
| 22 | + const [debouncedValue, setDebouncedValue] = useState<T>(value) |
| 23 | + |
| 24 | + useEffect(() => { |
| 25 | + const handler = setTimeout(() => { |
| 26 | + setDebouncedValue(value) |
| 27 | + }, delay) |
| 28 | + |
| 29 | + return () => { |
| 30 | + clearTimeout(handler) |
| 31 | + } |
| 32 | + }, [value, delay]) |
| 33 | + |
| 34 | + return debouncedValue |
| 35 | +} |
| 36 | + |
| 37 | +export function LookupField({ |
| 38 | + value, |
| 39 | + onChange, |
| 40 | + disabled, |
| 41 | + readOnly, |
| 42 | + className, |
| 43 | + placeholder, |
| 44 | + error, |
| 45 | + label, |
| 46 | + required, |
| 47 | + description, |
| 48 | + name, |
| 49 | + referenceTo, // The object name we are looking up |
| 50 | +}: LookupFieldProps) { |
| 51 | + const [open, setOpen] = useState(false) |
| 52 | + const [items, setItems] = useState<any[]>([]) |
| 53 | + const [loading, setLoading] = useState(false) |
| 54 | + const [contentLabel, setContentLabel] = useState<string>("") |
| 55 | + const [search, setSearch] = useState("") |
| 56 | + |
| 57 | + const debouncedSearch = useDebounce(search, 300) |
| 58 | + |
| 59 | + // Fetch initial label if value exists but we don't have the label |
| 60 | + useEffect(() => { |
| 61 | + if (value && !contentLabel) { |
| 62 | + // If value is an object (expanded), use it |
| 63 | + if (typeof value === 'object' && (value as any).name) { |
| 64 | + setContentLabel((value as any).name || (value as any).title || (value as any)._id); |
| 65 | + return; |
| 66 | + } |
| 67 | + |
| 68 | + if (typeof value !== 'string') return; |
| 69 | + |
| 70 | + // Fetch single record to get label |
| 71 | + fetch(`/api/object/${referenceTo}/${value}`) |
| 72 | + .then(res => res.json()) |
| 73 | + .then(data => { |
| 74 | + if (data) { |
| 75 | + setContentLabel(data.name || data.title || data.email || data._id || value); |
| 76 | + } |
| 77 | + }) |
| 78 | + .catch(() => setContentLabel(value)); // Fallback to ID |
| 79 | + } |
| 80 | + }, [value, referenceTo]); |
| 81 | + |
| 82 | + // Search items |
| 83 | + useEffect(() => { |
| 84 | + if (!open) return; |
| 85 | + |
| 86 | + setLoading(true); |
| 87 | + const params = new URLSearchParams(); |
| 88 | + |
| 89 | + if (debouncedSearch) { |
| 90 | + // Try simple search first |
| 91 | + // In a real ObjectQL implementation this should use filters |
| 92 | + const filter = JSON.stringify([['name', 'contains', debouncedSearch], 'or', ['title', 'contains', debouncedSearch]]); |
| 93 | + params.append('filters', filter); |
| 94 | + } |
| 95 | + |
| 96 | + // Always limit results |
| 97 | + params.append('top', '20'); |
| 98 | + |
| 99 | + fetch(`/api/object/${referenceTo}?${params.toString()}`) |
| 100 | + .then(res => res.json()) |
| 101 | + .then(data => { |
| 102 | + const list = Array.isArray(data) ? data : (data.list || []); |
| 103 | + setItems(list); |
| 104 | + }) |
| 105 | + .catch(console.error) |
| 106 | + .finally(() => setLoading(false)); |
| 107 | + |
| 108 | + }, [open, debouncedSearch, referenceTo]); |
| 109 | + |
| 110 | + const handleSelect = (currentValue: string, item: any) => { |
| 111 | + onChange?.(currentValue === value ? undefined : currentValue) |
| 112 | + setContentLabel(item.name || item.title || item.email || item._id); |
| 113 | + setOpen(false) |
| 114 | + } |
| 115 | + |
| 116 | + const handleClear = (e: React.MouseEvent) => { |
| 117 | + e.stopPropagation(); |
| 118 | + onChange?.(undefined); |
| 119 | + setContentLabel(""); |
| 120 | + } |
| 121 | + |
| 122 | + return ( |
| 123 | + <div className={cn("grid gap-2", className)}> |
| 124 | + {label && ( |
| 125 | + <Label htmlFor={name} className={cn(error && "text-destructive")}> |
| 126 | + {label} |
| 127 | + {required && <span className="text-destructive ml-1">*</span>} |
| 128 | + </Label> |
| 129 | + )} |
| 130 | + <Popover open={open} onOpenChange={setOpen}> |
| 131 | + <PopoverTrigger asChild> |
| 132 | + <Button |
| 133 | + id={name} |
| 134 | + variant="outline" |
| 135 | + role="combobox" |
| 136 | + aria-expanded={open} |
| 137 | + className={cn( |
| 138 | + "w-full justify-between", |
| 139 | + !value && "text-muted-foreground", |
| 140 | + error && "border-destructive focus-visible:ring-destructive" |
| 141 | + )} |
| 142 | + disabled={disabled || readOnly} |
| 143 | + > |
| 144 | + {value ? contentLabel || value : (placeholder || "Select record...")} |
| 145 | + {value && !disabled && !readOnly ? ( |
| 146 | + <X className="ml-2 h-4 w-4 opacity-50 hover:opacity-100" onClick={handleClear} /> |
| 147 | + ) : ( |
| 148 | + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> |
| 149 | + )} |
| 150 | + </Button> |
| 151 | + </PopoverTrigger> |
| 152 | + <PopoverContent className="w-full p-0" align="start"> |
| 153 | + <Command shouldFilter={false}> |
| 154 | + <CommandInput |
| 155 | + placeholder={`Search ${referenceTo}...`} |
| 156 | + value={search} |
| 157 | + onValueChange={setSearch} |
| 158 | + /> |
| 159 | + {loading && <div className="py-6 text-center text-sm text-muted-foreground flex justify-center"><Loader2 className="h-4 w-4 animate-spin mr-2" /> Loading...</div>} |
| 160 | + |
| 161 | + {!loading && ( |
| 162 | + <CommandGroup className="max-h-[200px] overflow-auto"> |
| 163 | + {items.length === 0 ? ( |
| 164 | + <CommandEmpty>No results found.</CommandEmpty> |
| 165 | + ) : ( |
| 166 | + items.map((item) => { |
| 167 | + const itemId = item._id || item.id; |
| 168 | + const itemLabel = item.name || item.title || item.email || itemId; |
| 169 | + return ( |
| 170 | + <CommandItem |
| 171 | + key={itemId} |
| 172 | + value={itemId} |
| 173 | + onSelect={() => handleSelect(itemId, item)} |
| 174 | + > |
| 175 | + <Check |
| 176 | + className={cn( |
| 177 | + "mr-2 h-4 w-4", |
| 178 | + value === itemId ? "opacity-100" : "opacity-0" |
| 179 | + )} |
| 180 | + /> |
| 181 | + {itemLabel} |
| 182 | + </CommandItem> |
| 183 | + ) |
| 184 | + }) |
| 185 | + )} |
| 186 | + </CommandGroup> |
| 187 | + )} |
| 188 | + </Command> |
| 189 | + </PopoverContent> |
| 190 | + </Popover> |
| 191 | + {description && !error && ( |
| 192 | + <p className="text-sm text-muted-foreground">{description}</p> |
| 193 | + )} |
| 194 | + {error && <p className="text-sm text-destructive">{error}</p>} |
| 195 | + </div> |
| 196 | + ) |
| 197 | +} |
0 commit comments