Spaces:
Sleeping
Sleeping
| import { useEffect, useMemo, useState } from "react"; | |
| import { Check, Database, FileText, Loader2, RefreshCw } from "lucide-react"; | |
| import { | |
| getDatabaseClients, | |
| getDocuments, | |
| type DataBindItem, | |
| } from "@/services/orchestrationApi"; | |
| import { getCurrentSession } from "./session"; | |
| import { cx, uniqueById } from "./utils"; | |
| interface DataBindSelectorProps { | |
| value: DataBindItem[]; | |
| onChange: (items: DataBindItem[]) => void; | |
| disabled?: boolean; | |
| } | |
| export function DataBindSelector({ value, onChange, disabled }: DataBindSelectorProps) { | |
| const [options, setOptions] = useState<DataBindItem[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const selected = useMemo(() => new Set(value.map((item) => item.id)), [value]); | |
| const loadOptions = async () => { | |
| const session = getCurrentSession(); | |
| if (!session?.user_id) return; | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const [docs, dbs] = await Promise.all([getDocuments(session.user_id), getDatabaseClients(session.user_id)]); | |
| const docItems = docs | |
| .filter((doc) => ["processed", "completed", "uploaded", "processing"].includes(doc.status)) | |
| .map<DataBindItem>((doc) => ({ | |
| id: doc.id, | |
| name: doc.filename, | |
| group_type: "document", | |
| type: doc.file_type, | |
| })); | |
| const dbItems = dbs | |
| .filter((db) => db.status !== "inactive") | |
| .map<DataBindItem>((db) => ({ | |
| id: db.id, | |
| name: db.name, | |
| group_type: "database", | |
| type: db.db_type, | |
| })); | |
| setOptions(uniqueById([...docItems, ...dbItems])); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : "Failed to load sources"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| loadOptions(); | |
| }, []); | |
| const toggle = (item: DataBindItem) => { | |
| if (disabled) return; | |
| if (selected.has(item.id)) { | |
| onChange(value.filter((existing) => existing.id !== item.id)); | |
| return; | |
| } | |
| onChange([...value, item]); | |
| }; | |
| return ( | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between"> | |
| <label className="text-xs font-medium text-slate-600">Data binding</label> | |
| <button | |
| type="button" | |
| onClick={loadOptions} | |
| disabled={disabled || loading} | |
| className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100 disabled:opacity-40" | |
| > | |
| {loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />} | |
| Refresh | |
| </button> | |
| </div> | |
| {error && <p className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{error}</p>} | |
| <div className="max-h-56 space-y-2 overflow-y-auto rounded-lg border border-slate-200 bg-white p-2"> | |
| {loading ? ( | |
| <div className="flex items-center justify-center gap-2 py-8 text-sm text-slate-500"> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| Loading sources | |
| </div> | |
| ) : options.length === 0 ? ( | |
| <div className="py-8 text-center text-sm text-slate-500">No eligible sources yet.</div> | |
| ) : ( | |
| options.map((item) => { | |
| const isSelected = selected.has(item.id); | |
| const Icon = item.group_type === "database" ? Database : FileText; | |
| return ( | |
| <button | |
| type="button" | |
| key={`${item.group_type}-${item.id}`} | |
| onClick={() => toggle(item)} | |
| disabled={disabled} | |
| className={cx( | |
| "flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition disabled:opacity-50", | |
| isSelected ? "border-emerald-200 bg-emerald-50" : "border-transparent hover:border-slate-200 hover:bg-slate-50" | |
| )} | |
| > | |
| <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-600"> | |
| <Icon className="h-4 w-4" /> | |
| </span> | |
| <span className="min-w-0 flex-1"> | |
| <span className="block truncate text-sm font-medium text-slate-800">{item.name}</span> | |
| <span className="block text-xs capitalize text-slate-500">{item.group_type} / {item.type}</span> | |
| </span> | |
| {isSelected && <Check className="h-4 w-4 text-emerald-600" />} | |
| </button> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| {value.length === 0 && <p className="text-xs text-red-600">Select at least one source.</p>} | |
| </div> | |
| ); | |
| } | |