"use client"; import { useState, useEffect, useRef, useCallback } from "react"; type Option = { id: string; label: string }; type RelationSelectProps = { /** Name of the related object (e.g. "companies") */ relatedObjectName: string; /** Current value — single ID string or JSON array of IDs */ value: string; /** many_to_one = single select, many_to_many = multi-select */ multiple?: boolean; /** Called when selection changes; value is a single ID or JSON array */ onChange: (value: string) => void; /** Placeholder when nothing is selected */ placeholder?: string; /** Visual variant: "modal" for form fields, "inline" for table cells */ variant?: "modal" | "inline"; /** Auto-focus the search input on mount */ autoFocus?: boolean; }; function parseRelationValue(value: string | null | undefined): string[] { if (!value) {return [];} const trimmed = value.trim(); if (!trimmed) {return [];} if (trimmed.startsWith("[")) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);} } catch { /* not JSON */ } } return [trimmed]; } export function RelationSelect({ relatedObjectName, value, multiple = false, onChange, placeholder, variant = "modal", autoFocus = false, }: RelationSelectProps) { const [open, setOpen] = useState(autoFocus); const [search, setSearch] = useState(""); const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false); const [selectedIds, setSelectedIds] = useState(() => parseRelationValue(value)); const containerRef = useRef(null); const inputRef = useRef(null); const debounceRef = useRef | null>(null); // Sync external value changes useEffect(() => { setSelectedIds(parseRelationValue(value)); }, [value]); // Fetch options when dropdown opens or search changes const fetchOptions = useCallback(async (query: string) => { setLoading(true); try { const params = new URLSearchParams(); if (query) {params.set("q", query);} const res = await fetch( `/api/workspace/objects/${encodeURIComponent(relatedObjectName)}/entries/options?${params}`, ); if (res.ok) { const data = await res.json(); setOptions(data.options ?? []); } } catch { /* ignore */ } finally { setLoading(false); } }, [relatedObjectName]); useEffect(() => { if (open) { void fetchOptions(search); } }, [open]); // eslint-disable-line react-hooks/exhaustive-deps // Debounced search useEffect(() => { if (!open) {return;} if (debounceRef.current) {clearTimeout(debounceRef.current);} debounceRef.current = setTimeout(() => fetchOptions(search), 250); return () => { if (debounceRef.current) {clearTimeout(debounceRef.current);} }; }, [search]); // eslint-disable-line react-hooks/exhaustive-deps // Focus input when opening useEffect(() => { if (open && inputRef.current) { inputRef.current.focus(); } }, [open]); // Close on outside click useEffect(() => { if (!open) {return;} const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); const toggleSelect = (id: string) => { if (multiple) { const next = selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id]; setSelectedIds(next); onChange(next.length === 0 ? "" : JSON.stringify(next)); } else { setSelectedIds([id]); onChange(id); setOpen(false); } }; const removeId = (id: string) => { const next = selectedIds.filter((x) => x !== id); setSelectedIds(next); if (multiple) { onChange(next.length === 0 ? "" : JSON.stringify(next)); } else { onChange(""); } }; // Find labels for currently selected IDs (from loaded options, fallback to ID) const selectedLabels = selectedIds.map((id) => { const opt = options.find((o) => o.id === id); const rawLabel = opt?.label ?? id; return { id, label: String(rawLabel != null && typeof rawLabel === "object" ? JSON.stringify(rawLabel) : (rawLabel ?? "")) }; }); const isInline = variant === "inline"; return (
{/* Trigger / display area */}
setOpen(!open)} className={`w-full flex items-center flex-wrap gap-1 cursor-pointer min-h-[1.5em] ${isInline ? "text-xs" : "px-3 py-2 text-sm rounded-lg"}`} style={isInline ? {} : { background: "var(--color-surface)", color: "var(--color-text)", border: "1px solid var(--color-border)", }} > {selectedLabels.length > 0 ? ( selectedLabels.map(({ id, label }) => ( {String(label ?? "")} )) ) : ( {placeholder ?? `Select ${relatedObjectName}...`} )} {/* Chevron */}
{/* Dropdown */} {open && (
{/* Search input */}
setSearch(e.target.value)} placeholder={`Search ${relatedObjectName}...`} className="w-full px-2.5 py-1.5 text-xs rounded-md outline-none" style={{ background: "var(--color-surface)", color: "var(--color-text)", border: "1px solid var(--color-border)", }} onKeyDown={(e) => { if (e.key === "Escape") {setOpen(false);} }} />
{/* Options list */}
{loading ? (
Loading...
) : options.length === 0 ? (
{search ? "No matches found" : "No entries"}
) : ( options.map((opt) => { const isSelected = selectedIds.includes(opt.id); return ( ); }) )}
)}
); }