"use client"; 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 ─── */ 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; entries: Record>; }; type ServerPaginationProps = { totalCount: number; page: number; pageSize: number; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; }; type ObjectTableProps = { objectName: string; fields: Field[]; entries: Record[]; members?: Array<{ id: string; name: string }>; relationLabels?: Record>; reverseRelations?: ReverseRelation[]; onNavigateToObject?: (objectName: string) => void; onEntryClick?: (entryId: string) => void; onRefresh?: () => void; /** Column visibility state keyed by field ID. */ columnVisibility?: Record; /** Server-side pagination props. */ serverPagination?: ServerPaginationProps; /** Server-side search callback. */ onServerSearch?: (query: string) => void; }; type EntryRow = Record & { entry_id?: string }; /* ─── Helpers ─── */ /** Safely convert unknown (DuckDB) value to string for display. */ 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);} // symbol, function return ""; } 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 (read-only display) ─── */ 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 ( {value} ); } function BooleanCell({ value }: { value: unknown }) { const isTrue = value === true || value === "true" || value === "1" || value === "yes"; return ( {isTrue ? "Yes" : "No"} ); } function UserCell({ value, members }: { value: unknown; members?: Array<{ id: string; name: string }> }) { const memberId = String(value); const member = members?.find((m) => m.id === memberId); return ( {(member?.name ?? memberId).charAt(0).toUpperCase()} {member?.name ?? memberId} ); } function RelationCell({ value, field, relationLabels, onNavigate, }: { value: unknown; field: Field; relationLabels?: Record>; onNavigate?: (objectName: string) => void; }) { const fieldLabels = relationLabels?.[field.name]; const ids = parseRelationValue(String(value)); if (ids.length === 0) {return --;} return ( {ids.map((id) => ( { if (field.related_object_name && onNavigate) { e.stopPropagation(); onNavigate(field.related_object_name); } }} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${field.related_object_name && onNavigate ? "cursor-pointer" : ""}`} style={{ background: "var(--color-chip-document)", color: "var(--color-chip-document-text)", border: "1px solid var(--color-border)" }} > {fieldLabels?.[id] ?? id} ))} ); } function ReverseRelationCell({ links, sourceObjectName, onNavigate }: { links: Array<{ id: string; label: string }>; sourceObjectName: string; onNavigate?: (objectName: string) => void; }) { if (!links || links.length === 0) {return --;} const display = links.slice(0, 5); const overflow = links.length - display.length; return ( {display.map((link) => ( { e.stopPropagation(); onNavigate?.(sourceObjectName); }} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium cursor-pointer" style={{ background: "var(--color-chip-database)", color: "var(--color-chip-database-text)", border: "1px solid var(--color-border)" }} > {link.label} ))} {overflow > 0 && +{overflow}} ); } /* ─── Inline Edit Cell ─── */ function EditableCell({ value: initialValue, entryId, fieldName, objectName, field, members, relationLabels, onNavigate, onSaved, }: { value: unknown; entryId: string; fieldName: string; objectName: string; field: Field; members?: Array<{ id: string; name: string }>; relationLabels?: Record>; onNavigate?: (objectName: string) => void; onSaved?: () => void; }) { const [editing, setEditing] = useState(false); const [localValue, setLocalValue] = useState(safeString(initialValue)); const inputRef = useRef(null); const saveTimerRef = useRef | null>(null); // Sync with prop changes useEffect(() => { if (!editing) {setLocalValue(safeString(initialValue));} }, [initialValue, editing]); // Focus input on edit start useEffect(() => { if (editing && inputRef.current) {inputRef.current.focus();} }, [editing]); // 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 { await fetch(`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields: { [fieldName]: val } }), }); onSaved?.(); } catch { /* ignore */ } }, [objectName, entryId, fieldName, onSaved]); const handleChange = (val: string) => { setLocalValue(val); if (saveTimerRef.current) {clearTimeout(saveTimerRef.current);} saveTimerRef.current = setTimeout(() => save(val), 500); }; const handleBlur = () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); void save(localValue); } setEditing(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { handleBlur(); } if (e.key === "Escape") { setEditing(false); setLocalValue(safeString(initialValue)); } }; // Read-only display for non-editable types if (!isEditable) { if (field.type === "user") {return ;} return {safeString(initialValue)}; } // Editing mode — Excel-style seamless inline editing if (editing) { let editInput; if (isRelation) { return (
{ void save(v); setEditing(false); }} variant="inline" autoFocus />
); } if (field.type === "enum" && field.enum_values) { editInput = ( ); } else if (field.type === "boolean") { editInput = ( ); } else { editInput = ( } type={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"} value={localValue} onChange={(e) => handleChange(e.target.value)} onBlur={handleBlur} onKeyDown={handleKeyDown} className="w-full text-xs outline-none bg-transparent" style={{ color: "var(--color-text)" }} /> ); } return (
{editInput}
); } // Display mode — double-click to edit const displayValue = initialValue; // Relation fields: show chips with double-click to edit if (isRelation) { return (
setEditing(true)} className="cursor-cell min-h-[1.5em]" title="Double-click to edit" >
); } return (
setEditing(true)} className="cursor-cell min-h-[1.5em]" title="Double-click to edit" > {displayValue === null || displayValue === undefined || displayValue === "" ? ( -- ) : field.type === "enum" ? ( ) : field.type === "boolean" ? ( ) : field.type === "email" ? ( e.stopPropagation()}> {safeString(displayValue)} ) : field.type === "number" ? ( {safeString(displayValue)} ) : ( {safeString(displayValue)} )}
); } /* ─── Main ObjectTable ─── */ export function ObjectTable({ objectName, fields, entries, members, relationLabels, reverseRelations, onNavigateToObject, onEntryClick, onRefresh, columnVisibility, serverPagination, onServerSearch, }: ObjectTableProps) { const [rowSelection, setRowSelection] = useState>({}); const [showAddModal, setShowAddModal] = useState(false); const activeReverseRelations = useMemo(() => { if (!reverseRelations) {return [];} return reverseRelations.filter((rr) => Object.keys(rr.entries).length > 0); }, [reverseRelations]); // Build TanStack columns from fields const columns = useMemo[]>(() => { const cols: ColumnDef[] = fields.map((field, fieldIdx) => ({ id: field.id, accessorKey: field.name, meta: { label: field.name, fieldName: field.name }, header: () => ( {field.name} {field.type === "relation" && field.related_object_name && ( ({field.related_object_name}) )} ), cell: (info: CellContext) => { const eid = info.row.original.entry_id; const entryId = String(eid != null && typeof eid === "object" ? JSON.stringify(eid) : (eid ?? "")); // First column (sticky): bold link that opens the entry detail modal if (fieldIdx === 0 && onEntryClick) { const val = info.getValue(); const displayVal = val === null || val === undefined || val === "" ? "--" : safeString(val); const isEmpty = displayVal === "--"; return ( { e.stopPropagation(); if (entryId && !isEmpty) {onEntryClick(entryId);} }} > {displayVal} ); } return ( ); }, size: field.type === "richtext" ? 300 : field.type === "relation" ? 200 : 180, enableSorting: true, })); // Add reverse relation columns for (const rr of activeReverseRelations) { cols.push({ id: `rev_${rr.sourceObjectName}_${rr.fieldName}`, meta: { label: `${rr.sourceObjectName} (via ${rr.fieldName})` }, header: () => ( {rr.sourceObjectName} via {rr.fieldName} ), cell: (info: CellContext) => { const eid = info.row.original.entry_id; const entryId = String(eid != null && typeof eid === "object" ? JSON.stringify(eid) : (eid ?? "")); const links = rr.entries[entryId] ?? []; return ; }, enableSorting: false, size: 200, }); } return cols; }, [fields, activeReverseRelations, objectName, members, relationLabels, onNavigateToObject, onRefresh]); // Add entry handler — opens modal instead of creating empty entry const handleAdd = useCallback(() => { setShowAddModal(true); }, []); // Bulk delete handler const handleBulkDelete = useCallback(async () => { const selectedIds = Object.keys(rowSelection) .filter((k) => rowSelection[k]) .map((idx) => safeString(entries[Number(idx)]?.entry_id)) .filter(Boolean); if (selectedIds.length === 0) {return;} if (!confirm(`Delete ${selectedIds.length} entries?`)) {return;} try { await fetch(`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/bulk-delete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entryIds: selectedIds }), }); setRowSelection({}); onRefresh?.(); } catch { /* ignore */ } }, [rowSelection, entries, objectName, onRefresh]); // Single delete handler const handleDeleteEntry = useCallback(async (entry: EntryRow) => { const eid = entry.entry_id; const entryId = String(eid != null && typeof eid === "object" ? JSON.stringify(eid) : (eid ?? "")); if (!entryId) {return;} if (!confirm("Delete this entry?")) {return;} try { await fetch(`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`, { method: "DELETE", }); onRefresh?.(); } catch { /* ignore */ } }, [objectName, onRefresh]); // Row actions const getRowActions = useCallback( (_row: EntryRow): RowAction[] => { const actions: RowAction[] = []; if (onEntryClick) { actions.push({ label: "View details", onClick: (r) => { const eid = String(r.entry_id ?? ""); if (eid) {onEntryClick(eid);} }, icon: , }); } actions.push({ label: "Delete", variant: "destructive", onClick: handleDeleteEntry, icon: , }); return actions; }, [onEntryClick, handleDeleteEntry], ); // Column reorder handler const handleColumnReorder = useCallback( async (newOrder: string[]) => { // Map column IDs back to field IDs (exclude select, actions, and reverse relations) const fieldIds = newOrder.filter((id) => !id.startsWith("rev_") && id !== "select" && id !== "actions"); try { await fetch(`/api/workspace/objects/${encodeURIComponent(objectName)}/fields/reorder`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fieldOrder: fieldIds }), }); } catch { /* ignore */ } }, [objectName], ); // Bulk actions toolbar const bulkActions = ( ); return ( <> {/* Add Entry Modal */} {showAddModal && ( setShowAddModal(false)} onSaved={onRefresh} /> )} ); } /* ─── Add Entry Modal ─── */ function AddEntryModal({ objectName, fields, members, onClose, onSaved, }: { objectName: string; fields: Field[]; members?: Array<{ id: string; name: string }>; onClose: () => void; onSaved?: () => void; }) { const [values, setValues] = useState>({}); const [saving, setSaving] = useState(false); const backdropRef = useRef(null); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") {onClose();} }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [onClose]); const updateField = (name: string, value: string) => { setValues((prev) => ({ ...prev, [name]: value })); }; const handleSave = async () => { setSaving(true); try { const res = await fetch( `/api/workspace/objects/${encodeURIComponent(objectName)}/entries`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields: values }), }, ); if (res.ok) { onSaved?.(); onClose(); } } catch { /* ignore */ } finally { setSaving(false); } }; return (
{ if (e.target === backdropRef.current) {onClose();} }} className="fixed inset-0 z-50 flex items-start justify-center" style={{ background: "rgba(0, 0, 0, 0.5)", backdropFilter: "blur(2px)" }} >
{/* Header */}

Add {objectName}

{/* Form */}
{ e.preventDefault(); void handleSave(); }} className="flex-1 overflow-y-auto px-6 py-5 space-y-4" > {fields.map((field) => { const isRelation = field.type === "relation"; const isUser = field.type === "user"; return (
{field.type === "enum" && field.enum_values ? ( ) : field.type === "boolean" ? ( ) : field.type === "richtext" ? (