FEAT: Add RelationSelect component and enhance entry editing

- 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.
This commit is contained in:
kumarabhirup 2026-02-12 18:31:55 -08:00
parent c2a302b582
commit d68b9350c6
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
5 changed files with 450 additions and 8 deletions

View File

@ -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<ObjectRow>(
`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<FieldRow>(
`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 });
}

View File

@ -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 ? (
<div className="flex items-center gap-2 w-full">
<div className="flex-1">
<RelationSelect
relatedObjectName={field.related_object_name}
value={String(value ?? "")}
multiple={field.relationship_type === "many_to_many"}
onChange={(v) => { handleSaveField(field.name, v); }}
autoFocus
/>
</div>
<button type="button" onClick={() => setEditingField(null)} className="px-2 py-1 text-xs rounded-lg flex-shrink-0" style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}>
Cancel
</button>
</div>
) : (
<form
onSubmit={(e) => { e.preventDefault(); handleSaveField(field.name, editValue); }}
className="flex items-center gap-2 w-full"
@ -556,16 +573,17 @@ export function EntryDetailModal({
Cancel
</button>
</form>
)
) : (
<div
className={`flex-1 ${!["relation", "user"].includes(field.type) ? "cursor-pointer hover:opacity-80" : ""}`}
className={`flex-1 ${!["user"].includes(field.type) ? "cursor-pointer hover:opacity-80" : ""}`}
onClick={() => {
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}
>
<FieldValue
value={value}

View File

@ -3,6 +3,7 @@
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
import { type ColumnDef, type CellContext } from "@tanstack/react-table";
import { DataTable, type RowAction } from "./data-table";
import { RelationSelect } from "./relation-select";
/* ─── Types ─── */
@ -182,8 +183,9 @@ function EditableCell({
if (editing && inputRef.current) {inputRef.current.focus();}
}, [editing]);
// Non-editable types: render read-only
const isEditable = !["relation", "user"].includes(field.type);
// Non-editable types: render read-only (relations are now editable via dropdown)
const isEditable = !["user"].includes(field.type);
const isRelation = field.type === "relation" && !!field.related_object_name;
const save = useCallback(async (val: string) => {
try {
@ -214,7 +216,6 @@ function EditableCell({
// Read-only display for non-editable types
if (!isEditable) {
if (field.type === "relation") {return <RelationCell value={initialValue} field={field} relationLabels={relationLabels} onNavigate={onNavigate} />;}
if (field.type === "user") {return <UserCell value={initialValue} members={members} />;}
return <span className="truncate block max-w-[300px]">{String(initialValue ?? "")}</span>;
}
@ -222,6 +223,26 @@ function EditableCell({
// Editing mode — Excel-style seamless inline editing
if (editing) {
let editInput;
if (isRelation) {
return (
<div
className="-mx-3 -my-2 px-3 py-2"
style={{
background: "var(--color-bg)",
boxShadow: "inset 0 0 0 2px var(--color-accent)",
}}
>
<RelationSelect
relatedObjectName={field.related_object_name!}
value={String(initialValue ?? "")}
multiple={field.relationship_type === "many_to_many"}
onChange={(v) => { save(v); setEditing(false); }}
variant="inline"
autoFocus
/>
</div>
);
}
if (field.type === "enum" && field.enum_values) {
editInput = (
<select
@ -283,6 +304,20 @@ function EditableCell({
// Display mode — double-click to edit
const displayValue = initialValue;
// Relation fields: show chips with double-click to edit
if (isRelation) {
return (
<div
onDoubleClick={() => setEditing(true)}
className="cursor-cell min-h-[1.5em]"
title="Double-click to edit"
>
<RelationCell value={initialValue} field={field} relationLabels={relationLabels} onNavigate={onNavigate} />
</div>
);
}
return (
<div
onDoubleClick={() => setEditing(true)}
@ -685,6 +720,14 @@ function AddEntryModal({
}}
placeholder={field.name}
/>
) : isRelation && field.related_object_name ? (
<RelationSelect
relatedObjectName={field.related_object_name}
value={values[field.name] ?? ""}
multiple={field.relationship_type === "many_to_many"}
onChange={(v) => updateField(field.name, v)}
placeholder={`Select ${field.related_object_name}...`}
/>
) : isUser ? (
<select
value={values[field.name] ?? ""}
@ -712,7 +755,7 @@ function AddEntryModal({
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
placeholder={isRelation ? `${field.related_object_name ?? field.name} ID` : field.name}
placeholder={field.name}
/>
)}
</div>

View File

@ -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<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) {
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 (
<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]">{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">{opt.label}</span>
</button>
);
})
)}
</div>
</div>
)}
</div>
);
}

View File

@ -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<string, ActiveRun>();
const GLOBAL_KEY = "__openclaw_activeRuns" as const;
const activeRuns: Map<string, ActiveRun> =
(globalThis as Record<string, unknown>)[GLOBAL_KEY] as Map<string, ActiveRun> ??
new Map<string, ActiveRun>();
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = activeRuns;
// ── Public API ──