openclaw/apps/web/app/components/workspace/entry-detail-modal.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

653 lines
24 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { RelationSelect } from "./relation-select";
function safeString(val: unknown): string {
if (val == null) {return "";}
if (typeof val === "object") {return JSON.stringify(val);}
if (typeof val === "string") {return val;}
if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);}
return "";
}
// --- Types ---
type Field = {
id: string;
name: string;
type: string;
enum_values?: string[];
enum_colors?: string[];
enum_multiple?: boolean;
related_object_id?: string;
relationship_type?: string;
related_object_name?: string;
sort_order?: number;
};
type ReverseRelation = {
fieldName: string;
sourceObjectName: string;
sourceObjectId: string;
displayField: string;
links: Array<{ id: string; label: string }>;
};
type EntryDetailData = {
object: {
id: string;
name: string;
description?: string;
icon?: string;
};
fields: Field[];
entry: Record<string, unknown>;
relationLabels?: Record<string, Record<string, string>>;
reverseRelations?: ReverseRelation[];
effectiveDisplayField?: string;
};
type EntryDetailModalProps = {
objectName: string;
entryId: string;
members?: Array<{ id: string; name: string; email: string; role: string }>;
onClose: () => void;
/** Navigate to another entry (opens new modal). */
onNavigateEntry?: (objectName: string, entryId: string) => void;
/** Navigate to an object table view. */
onNavigateObject?: (objectName: string) => void;
/** Called after an edit or delete to refresh parent data. */
onRefresh?: () => void;
};
// --- Helpers ---
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];
}
// --- Cell renderers (lightweight variants of object-table ones) ---
function EnumBadge({
value,
enumValues,
enumColors,
}: {
value: string;
enumValues?: string[];
enumColors?: string[];
}) {
const idx = enumValues?.indexOf(value) ?? -1;
const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8";
return (
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium"
style={{
background: `${color}20`,
color,
border: `1px solid ${color}40`,
}}
>
{value}
</span>
);
}
function UserBadge({
value,
members,
}: {
value: unknown;
members?: Array<{ id: string; name: string }>;
}) {
const memberId = String(value);
const member = members?.find((m) => m.id === memberId);
return (
<span className="flex items-center gap-2">
<span
className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0"
style={{ background: "var(--color-accent)", color: "white" }}
>
{(member?.name ?? memberId).charAt(0).toUpperCase()}
</span>
<span>{member?.name ?? memberId}</span>
</span>
);
}
function RelationChips({
value,
field,
relationLabels,
onNavigateEntry,
}: {
value: unknown;
field: Field;
relationLabels?: Record<string, Record<string, string>>;
onNavigateEntry?: (objectName: string, entryId: string) => void;
}) {
const fieldLabels = relationLabels?.[field.name];
const ids = parseRelationValue(String(value));
if (ids.length === 0) {return <EmptyValue />;}
return (
<span className="flex items-center gap-1.5 flex-wrap">
{ids.map((id) => {
const label = fieldLabels?.[id] ?? id;
const handleClick = field.related_object_name && onNavigateEntry
? () => onNavigateEntry(field.related_object_name!, id)
: undefined;
return (
<button
type="button"
key={id}
onClick={handleClick}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${handleClick ? "cursor-pointer hover:opacity-80" : ""}`}
style={{
background: "rgba(96, 165, 250, 0.1)",
color: "#60a5fa",
border: "1px solid rgba(96, 165, 250, 0.2)",
}}
title={handleClick ? `Open ${label}` : label}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.5 }}>
<path d="M7 7h10v10" /><path d="M7 17 17 7" />
</svg>
<span className="truncate max-w-[200px]">{label}</span>
</button>
);
})}
</span>
);
}
function EmptyValue() {
return (
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>
);
}
/** Render a set of reverse relation links (incoming references from another object). */
function ReverseRelationSection({
relation,
onNavigateEntry,
}: {
relation: ReverseRelation;
onNavigateEntry?: (objectName: string, entryId: string) => void;
}) {
const displayLinks = relation.links.slice(0, 10);
const overflow = relation.links.length - displayLinks.length;
return (
<div>
<label
className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wider mb-1.5"
style={{ color: "var(--color-text-muted)" }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
</svg>
<span className="capitalize">{relation.sourceObjectName}</span>
<span className="normal-case tracking-normal font-normal opacity-60">
via {relation.fieldName}
</span>
</label>
<div className="flex items-center gap-1.5 flex-wrap text-sm min-h-[1.75rem]">
{displayLinks.map((link) => (
<button
type="button"
key={link.id}
onClick={() => onNavigateEntry?.(relation.sourceObjectName, link.id)}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium cursor-pointer hover:opacity-80"
style={{
background: "rgba(192, 132, 252, 0.1)",
color: "#c084fc",
border: "1px solid rgba(192, 132, 252, 0.2)",
}}
title={`Open ${link.label} in ${relation.sourceObjectName}`}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.5 }}>
<path d="M7 7h10v10" /><path d="M7 17 17 7" />
</svg>
<span className="truncate max-w-[200px]">{link.label}</span>
</button>
))}
{overflow > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ color: "var(--color-text-muted)" }}>
+{overflow} more
</span>
)}
</div>
</div>
);
}
function FieldValue({
value,
field,
members,
relationLabels,
onNavigateEntry,
}: {
value: unknown;
field: Field;
members?: Array<{ id: string; name: string }>;
relationLabels?: Record<string, Record<string, string>>;
onNavigateEntry?: (objectName: string, entryId: string) => void;
}) {
if (value === null || value === undefined || value === "") {return <EmptyValue />;}
switch (field.type) {
case "enum":
return (
<EnumBadge
value={safeString(value)}
enumValues={field.enum_values}
enumColors={field.enum_colors}
/>
);
case "boolean": {
const isTrue = value === true || value === "true" || value === "1" || value === "yes";
return <span style={{ color: isTrue ? "#22c55e" : "var(--color-text-muted)" }}>{isTrue ? "Yes" : "No"}</span>;
}
case "user":
return <UserBadge value={value} members={members} />;
case "relation":
return (
<RelationChips
value={value}
field={field}
relationLabels={relationLabels}
onNavigateEntry={onNavigateEntry}
/>
);
case "email":
return (
<a href={`mailto:${safeString(value)}`} className="underline underline-offset-2" style={{ color: "#60a5fa" }}>
{safeString(value)}
</a>
);
case "richtext":
return <span className="whitespace-pre-wrap">{safeString(value)}</span>;
case "number":
return <span className="tabular-nums">{safeString(value)}</span>;
case "date":
return <span>{safeString(value)}</span>;
default:
return <span>{safeString(value)}</span>;
}
}
// --- Modal Component ---
export function EntryDetailModal({
objectName,
entryId,
members,
onClose,
onNavigateEntry,
onNavigateObject,
onRefresh,
}: EntryDetailModalProps) {
const [data, setData] = useState<EntryDetailData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingField, setEditingField] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const backdropRef = useRef<HTMLDivElement>(null);
// Fetch entry data
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
async function load() {
try {
const res = await fetch(
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Failed to load" }));
if (!cancelled) {
setError(err.error ?? "Failed to load entry");
setLoading(false);
}
return;
}
const json = await res.json();
if (!cancelled) {
setData(json);
setLoading(false);
}
} catch {
if (!cancelled) {
setError("Network error");
setLoading(false);
}
}
}
void load();
return () => { cancelled = true; };
}, [objectName, entryId]);
// Close on Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {onClose();}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
// Close on backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === backdropRef.current) {onClose();}
},
[onClose],
);
// ── Edit handler ──
const handleSaveField = useCallback(async (fieldName: string, value: string) => {
setSaving(true);
try {
const res = await fetch(
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: { [fieldName]: value } }),
},
);
if (res.ok) {
// Update local data optimistically
setData((prev) => {
if (!prev) {return prev;}
return { ...prev, entry: { ...prev.entry, [fieldName]: value } };
});
setEditingField(null);
onRefresh?.();
}
} catch { /* ignore */ }
finally { setSaving(false); }
}, [objectName, entryId, onRefresh]);
// ── Delete handler ──
const handleDelete = useCallback(async () => {
if (!confirm("Are you sure you want to delete this entry?")) {return;}
setDeleting(true);
try {
await fetch(
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
{ method: "DELETE" },
);
onRefresh?.();
onClose();
} catch { /* ignore */ }
finally { setDeleting(false); }
}, [objectName, entryId, onRefresh, onClose]);
const displayField = data?.effectiveDisplayField;
const title = displayField && data?.entry[displayField]
? safeString(data.entry[displayField])
: `${String(objectName)} entry`;
return (
<div
ref={backdropRef}
onClick={handleBackdropClick}
className="fixed inset-0 z-50 flex items-start justify-center"
style={{ background: "rgba(0, 0, 0, 0.5)", backdropFilter: "blur(2px)" }}
>
<div
className="relative mt-4 mb-4 mx-3 md:mt-12 md:mb-12 md:mx-0 w-full max-w-2xl rounded-2xl overflow-hidden shadow-2xl flex flex-col"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
maxHeight: "calc(100vh - 2rem)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 md:px-6 md:py-4 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-3 min-w-0">
{/* Object badge */}
<button
type="button"
onClick={() => void onNavigateObject?.(objectName)}
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium capitalize transition-colors hover:opacity-80 flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
border: "1px solid var(--color-border)",
}}
title={`Go to ${objectName}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
</svg>
{objectName}
</button>
<h2
className="text-lg font-semibold truncate"
style={{ color: "var(--color-text)" }}
>
{loading ? "Loading..." : title}
</h2>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{/* Delete button */}
<button
type="button"
onClick={() => void handleDelete()}
disabled={deleting}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-error)" }}
title="Delete entry"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</button>
{/* Close button */}
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{loading && (
<div className="flex items-center justify-center py-16">
<div
className="w-6 h-6 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
</div>
)}
{error && (
<div className="text-center py-16">
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>{error}</p>
</div>
)}
{data && !loading && (
<div className="space-y-4">
{data.fields.map((field) => {
const value = data.entry[field.name];
return (
<div key={field.id}>
<label
className="block text-xs font-medium uppercase tracking-wider mb-1.5"
style={{ color: "var(--color-text-muted)" }}
>
{field.name}
{field.type === "relation" && field.related_object_name && (
<span className="normal-case tracking-normal font-normal opacity-60 ml-1">
({field.related_object_name})
</span>
)}
</label>
<div
className="text-sm min-h-[1.75rem] flex items-center"
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={safeString(value)}
multiple={field.relationship_type === "many_to_many"}
onChange={(v) => { void 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(); void handleSaveField(field.name, editValue); }}
className="flex items-center gap-2 w-full"
>
{field.type === "enum" && field.enum_values ? (
<select
value={editValue}
onChange={(e) => { setEditValue(e.target.value); void handleSaveField(field.name, e.target.value); }}
autoFocus
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
>
<option value="">--</option>
{field.enum_values.map((v) => <option key={v} value={v}>{v}</option>)}
</select>
) : field.type === "boolean" ? (
<select
value={editValue}
onChange={(e) => { setEditValue(e.target.value); void handleSaveField(field.name, e.target.value); }}
autoFocus
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
) : (
<>
<input
type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
className="flex-1 px-2 py-1 text-sm rounded-lg outline-none"
style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "2px solid var(--color-accent)" }}
/>
<button type="submit" disabled={saving} className="px-2 py-1 text-xs rounded-lg font-medium" style={{ background: "var(--color-accent)", color: "white" }}>
{saving ? "..." : "Save"}
</button>
</>
)}
<button type="button" onClick={() => setEditingField(null)} className="px-2 py-1 text-xs rounded-lg" style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}>
Cancel
</button>
</form>
)
) : (
<div
className={`flex-1 ${!["user"].includes(field.type) ? "cursor-pointer hover:opacity-80" : ""}`}
onClick={() => {
if (!["user"].includes(field.type)) {
setEditingField(field.name);
setEditValue(safeString(value));
}
}}
title={!["user"].includes(field.type) ? "Click to edit" : undefined}
>
<FieldValue
value={value}
field={field}
members={members}
relationLabels={data.relationLabels}
onNavigateEntry={onNavigateEntry}
/>
</div>
)}
</div>
</div>
);
})}
{/* Reverse relations (incoming links from other objects) */}
{data.reverseRelations && data.reverseRelations.length > 0 && (
<div
className="pt-4 mt-4 border-t space-y-4"
style={{ borderColor: "var(--color-border)" }}
>
<div
className="text-[10px] font-medium uppercase tracking-widest"
style={{ color: "var(--color-text-muted)", opacity: 0.6 }}
>
Linked from
</div>
{data.reverseRelations.map((rr) => (
<ReverseRelationSection
key={`${rr.sourceObjectName}_${rr.fieldName}`}
relation={rr}
onNavigateEntry={onNavigateEntry}
/>
))}
</div>
)}
{/* Timestamps */}
{(data.entry.created_at != null || data.entry.updated_at != null) && (
<div
className="pt-4 mt-4 border-t text-xs flex gap-6"
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
{data.entry.created_at != null && (
<span>Created: {safeString(data.entry.created_at)}</span>
)}
{data.entry.updated_at != null && (
<span>Updated: {safeString(data.entry.updated_at)}</span>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}