From d68b9350c6f5d5a11ab1de7be32dbd84ff789642 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Thu, 12 Feb 2026 18:31:55 -0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20FEAT:=20Add=20RelationSelect=20comp?= =?UTF-8?q?onent=20and=20enhance=20entry=20editing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduced a new RelationSelect component for managing relations in forms. - Updated EntryDetailModal and ObjectTable components to utilize RelationSelect for relation fields, allowing for inline editing and improved user experience. - Enhanced API route for fetching relation options based on user input. - Refactored EditableCell to support relation editing with dropdowns, improving data entry efficiency. - Added new API endpoint for fetching lightweight options for relation dropdowns. This update streamlines the handling of relational data within the workspace, enhancing the overall functionality and user interface. --- .../objects/[name]/entries/options/route.ts | 90 ++++++ .../workspace/entry-detail-modal.tsx | 24 +- .../app/components/workspace/object-table.tsx | 51 +++- .../components/workspace/relation-select.tsx | 282 ++++++++++++++++++ apps/web/lib/active-runs.ts | 11 +- 5 files changed, 450 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/api/workspace/objects/[name]/entries/options/route.ts create mode 100644 apps/web/app/components/workspace/relation-select.tsx diff --git a/apps/web/app/api/workspace/objects/[name]/entries/options/route.ts b/apps/web/app/api/workspace/objects/[name]/entries/options/route.ts new file mode 100644 index 00000000000..29997e4e62d --- /dev/null +++ b/apps/web/app/api/workspace/objects/[name]/entries/options/route.ts @@ -0,0 +1,90 @@ +import { duckdbQuery, duckdbPath } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +type ObjectRow = { + id: string; + name: string; + display_field?: string; +}; + +type FieldRow = { + id: string; + name: string; + type: string; +}; + +function sqlEscape(s: string): string { + return s.replace(/'/g, "''"); +} + +function resolveDisplayField( + obj: ObjectRow, + fields: FieldRow[], +): string { + if (obj.display_field) {return obj.display_field;} + const nameField = fields.find( + (f) => /\bname\b/i.test(f.name) || /\btitle\b/i.test(f.name), + ); + if (nameField) {return nameField.name;} + const textField = fields.find((f) => f.type === "text"); + if (textField) {return textField.name;} + return fields[0]?.name ?? "id"; +} + +/** + * GET /api/workspace/objects/[name]/entries/options + * Returns lightweight { options: [{ id, label }] } for relation dropdowns. + * Supports optional ?q= search parameter. + */ +export async function GET( + req: Request, + { params }: { params: Promise<{ name: string }> }, +) { + const { name } = await params; + + if (!duckdbPath()) { + return Response.json({ error: "DuckDB not found" }, { status: 404 }); + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + return Response.json({ error: "Invalid object name" }, { status: 400 }); + } + + const objects = duckdbQuery( + `SELECT * FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`, + ); + if (objects.length === 0) { + return Response.json({ error: `Object '${name}' not found` }, { status: 404 }); + } + const obj = objects[0]; + + const fields = duckdbQuery( + `SELECT * FROM fields WHERE object_id = '${sqlEscape(obj.id)}' ORDER BY sort_order`, + ); + const displayFieldName = resolveDisplayField(obj, fields); + + // Optional search filter + const url = new URL(req.url); + const query = url.searchParams.get("q")?.trim() ?? ""; + + // Fetch entries with their display field value + const rows = duckdbQuery<{ entry_id: string; label: string | null }>( + `SELECT e.id as entry_id, ef.value as label + FROM entries e + LEFT JOIN entry_fields ef ON ef.entry_id = e.id + LEFT JOIN fields f ON f.id = ef.field_id AND f.name = '${sqlEscape(displayFieldName)}' + WHERE e.object_id = '${sqlEscape(obj.id)}' + ${query ? `AND (ef.value IS NOT NULL AND LOWER(ef.value) LIKE '%${sqlEscape(query.toLowerCase())}%')` : ""} + ORDER BY ef.value ASC NULLS LAST + LIMIT 200`, + ); + + const options = rows.map((r) => ({ + id: r.entry_id, + label: r.label || r.entry_id, + })); + + return Response.json({ options, displayField: displayFieldName }); +} diff --git a/apps/web/app/components/workspace/entry-detail-modal.tsx b/apps/web/app/components/workspace/entry-detail-modal.tsx index 1908a6fa1bc..f57fae56648 100644 --- a/apps/web/app/components/workspace/entry-detail-modal.tsx +++ b/apps/web/app/components/workspace/entry-detail-modal.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState, useCallback, useRef } from "react"; +import { RelationSelect } from "./relation-select"; // --- Types --- @@ -511,6 +512,22 @@ export function EntryDetailModal({ style={{ color: "var(--color-text)" }} > {editingField === field.name ? ( + field.type === "relation" && field.related_object_name ? ( +
+
+ { handleSaveField(field.name, v); }} + autoFocus + /> +
+ +
+ ) : (
{ e.preventDefault(); handleSaveField(field.name, editValue); }} className="flex items-center gap-2 w-full" @@ -556,16 +573,17 @@ export function EntryDetailModal({ Cancel
+ ) ) : (
{ - if (!["relation", "user"].includes(field.type)) { + if (!["user"].includes(field.type)) { setEditingField(field.name); setEditValue(String(value ?? "")); } }} - title={!["relation", "user"].includes(field.type) ? "Click to edit" : undefined} + title={!["user"].includes(field.type) ? "Click to edit" : undefined} > { try { @@ -214,7 +216,6 @@ function EditableCell({ // Read-only display for non-editable types if (!isEditable) { - if (field.type === "relation") {return ;} if (field.type === "user") {return ;} return {String(initialValue ?? "")}; } @@ -222,6 +223,26 @@ function EditableCell({ // Editing mode — Excel-style seamless inline editing if (editing) { let editInput; + if (isRelation) { + return ( +
+ { save(v); setEditing(false); }} + variant="inline" + autoFocus + /> +
+ ); + } if (field.type === "enum" && field.enum_values) { editInput = ( )}
diff --git a/apps/web/app/components/workspace/relation-select.tsx b/apps/web/app/components/workspace/relation-select.tsx new file mode 100644 index 00000000000..104e55bf4e5 --- /dev/null +++ b/apps/web/app/components/workspace/relation-select.tsx @@ -0,0 +1,282 @@ +"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) { + 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); + return { id, label: opt?.label ?? id }; + }); + + 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 }) => ( + + {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 ( + + ); + }) + )} +
+
+ )} +
+ ); +} diff --git a/apps/web/lib/active-runs.ts b/apps/web/lib/active-runs.ts index 04a40c0a6b8..ee94a045ad9 100644 --- a/apps/web/lib/active-runs.ts +++ b/apps/web/lib/active-runs.ts @@ -76,8 +76,17 @@ const WEB_CHAT_DIR = join(homedir(), ".openclaw", "web-chat"); const INDEX_FILE = join(WEB_CHAT_DIR, "index.json"); // ── Singleton registry ── +// Store on globalThis so the Map survives Next.js HMR reloads in dev mode. +// Without this, hot-reloading any server module resets the Map, orphaning +// running child processes and dropping SSE streams mid-flight. -const activeRuns = new Map(); +const GLOBAL_KEY = "__openclaw_activeRuns" as const; + +const activeRuns: Map = + (globalThis as Record)[GLOBAL_KEY] as Map ?? + new Map(); + +(globalThis as Record)[GLOBAL_KEY] = activeRuns; // ── Public API ──