openclaw/apps/web/app/components/workspace/relation-select.tsx
kumarabhirup dee323b7ad
fix lint/build errors and bump to 2026.2.15-1.4
- Fix all oxlint errors (curly, no-unused-vars, no-base-to-string,
  no-floating-promises, approx-constant, restrict-template-expressions)
- Fix TS build errors: rewrite update-cli.ts as thin wrapper over
  submodules, restore missing chat abort helpers in chat.ts
- Fix web build: wrap handleNewSession in async for ChatPanelHandle,
  add missing safeString helper to entry-detail-modal
- Bump version to 2026.2.15-1.4 and publish

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 00:30:13 -08:00

284 lines
9.4 KiB
TypeScript

"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<Option[]>([]);
const [loading, setLoading] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>(() => parseRelationValue(value));
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div ref={containerRef} className="relative w-full">
{/* Trigger / display area */}
<div
onClick={() => 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 }) => (
<span
key={id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium"
style={{
background: "rgba(96, 165, 250, 0.1)",
color: "#60a5fa",
border: "1px solid rgba(96, 165, 250, 0.2)",
}}
>
<span className="truncate max-w-[160px]">{String(label ?? "")}</span>
<button
type="button"
onClick={(e) => { e.stopPropagation(); removeId(id); }}
className="ml-0.5 hover:opacity-70"
style={{ color: "#60a5fa" }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</span>
))
) : (
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
{placeholder ?? `Select ${relatedObjectName}...`}
</span>
)}
{/* Chevron */}
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="ml-auto flex-shrink-0"
style={{ color: "var(--color-text-muted)", transform: open ? "rotate(180deg)" : undefined, transition: "transform 0.15s" }}
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
{/* Dropdown */}
{open && (
<div
className="absolute z-50 mt-1 w-full rounded-lg shadow-lg overflow-hidden"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
maxHeight: 260,
}}
>
{/* Search input */}
<div className="p-2 border-b" style={{ borderColor: "var(--color-border)" }}>
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => 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);}
}}
/>
</div>
{/* Options list */}
<div className="overflow-y-auto" style={{ maxHeight: 200 }}>
{loading ? (
<div className="px-3 py-4 text-center text-xs" style={{ color: "var(--color-text-muted)" }}>
Loading...
</div>
) : options.length === 0 ? (
<div className="px-3 py-4 text-center text-xs" style={{ color: "var(--color-text-muted)" }}>
{search ? "No matches found" : "No entries"}
</div>
) : (
options.map((opt) => {
const isSelected = selectedIds.includes(opt.id);
return (
<button
type="button"
key={opt.id}
onClick={() => toggleSelect(opt.id)}
className="w-full text-left px-3 py-2 text-xs flex items-center gap-2 transition-colors"
style={{
background: isSelected ? "var(--color-accent-light, rgba(96, 165, 250, 0.08))" : "transparent",
color: "var(--color-text)",
}}
onMouseEnter={(e) => { if (!isSelected) {(e.currentTarget.style.background = "var(--color-surface-hover, rgba(255,255,255,0.04))");}}}
onMouseLeave={(e) => { e.currentTarget.style.background = isSelected ? "var(--color-accent-light, rgba(96, 165, 250, 0.08))" : "transparent"; }}
>
{/* Checkbox for multi-select */}
{multiple && (
<span
className="w-3.5 h-3.5 rounded border flex items-center justify-center flex-shrink-0"
style={{
borderColor: isSelected ? "var(--color-accent)" : "var(--color-border)",
background: isSelected ? "var(--color-accent)" : "transparent",
}}
>
{isSelected && (
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
)}
</span>
)}
{/* Single-select check indicator */}
{!multiple && isSelected && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<path d="M20 6 9 17l-5-5" />
</svg>
)}
<span className="truncate">{String(opt.label != null && typeof opt.label === "object" ? JSON.stringify(opt.label) : (opt.label ?? ""))}</span>
</button>
);
})
)}
</div>
</div>
)}
</div>
);
}