"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"; import { FormattedFieldValue } from "./formatted-field-value"; import { formatWorkspaceFieldValue } from "@/lib/workspace-cell-format"; import { parseTagsValue } from "@/lib/parse-tags"; /* ─── 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; onNavigateToEntry?: (objectName: string, entryId: string) => void; onEntryClick?: (entryId: string) => void; onRefresh?: () => void; /** Column visibility state keyed by field ID. */ columnVisibility?: Record; onColumnVisibilityChanged?: (visibility: Record) => void; /** Server-side pagination props. */ serverPagination?: ServerPaginationProps; /** Server-side search callback. */ onServerSearch?: (query: string) => void; }; type EntryRow = Record & { entry_id?: string }; const CREATED_AT_KEYS = ["created_at", "Created", "createdAt", "created"] as const; const UPDATED_AT_KEYS = ["updated_at", "Updated", "updatedAt", "updated"] as const; /* ─── 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]; } function inputTypeForField(fieldType: string): React.HTMLInputTypeAttribute { switch (fieldType) { case "number": return "number"; case "date": return "date"; case "email": return "email"; case "phone": return "tel"; case "url": return "url"; default: return "text"; } } function resolveEntryMetaValue( entry: Record, candidateKeys: readonly string[], ): unknown { for (const key of candidateKeys) { const value = entry[key]; if (value !== null && value !== undefined && value !== "") { return value; } } return undefined; } /* ─── 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, onNavigateObject, onNavigateEntry, }: { value: unknown; field: Field; relationLabels?: Record>; onNavigateObject?: (objectName: string) => void; onNavigateEntry?: (objectName: string, entryId: 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) {return;} if (!onNavigateEntry && !onNavigateObject) {return;} e.stopPropagation(); if (onNavigateEntry) { onNavigateEntry(field.related_object_name, id); return; } onNavigateObject?.(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 && (onNavigateEntry || onNavigateObject) ? "cursor-pointer" : ""}`} style={{ background: "var(--color-chip-document)", color: "var(--color-chip-document-text)", border: "1px solid var(--color-border)" }} > {fieldLabels?.[id] ?? id} ))} ); } function TagChip({ tag }: { tag: string }) { const formatted = formatWorkspaceFieldValue(tag); const isLink = formatted.kind === "link" && formatted.href; const chipStyle = { background: "rgba(148, 163, 184, 0.12)", border: "1px solid var(--color-border)" }; if (isLink) { return ( e.stopPropagation()} className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium underline-offset-2 hover:underline" style={{ ...chipStyle, color: "var(--color-accent)" }} > {formatted.text} ); } return ( {tag} ); } function TagsCell({ value }: { value: unknown }) { const tags = parseTagsValue(value); if (tags.length === 0) {return --;} return ( {tags.slice(0, 5).map((tag) => )} {tags.length > 5 && +{tags.length - 5}} ); } function TagsInput({ value, onChange, autoFocus, }: { value: string; onChange: (val: string) => void; autoFocus?: boolean; }) { const tags = parseTagsValue(value); const [inputVal, setInputVal] = useState(""); const inputRef = useRef(null); useEffect(() => { if (autoFocus && inputRef.current) {inputRef.current.focus();} }, [autoFocus]); const addTag = (tag: string) => { const t = tag.trim(); if (!t || tags.includes(t)) {return;} const next = [...tags, t]; onChange(JSON.stringify(next)); setInputVal(""); }; const removeTag = (tag: string) => { const next = tags.filter((t) => t !== tag); onChange(next.length > 0 ? JSON.stringify(next) : ""); }; return (
{tags.map((tag) => ( {tag} ))} setInputVal(e.target.value)} onKeyDown={(e) => { if ((e.key === "Enter" || e.key === ",") && inputVal.trim()) { e.preventDefault(); addTag(inputVal); } if (e.key === "Backspace" && !inputVal && tags.length > 0) { removeTag(tags[tags.length - 1]); } }} onBlur={() => { if (inputVal.trim()) {addTag(inputVal);} }} placeholder={tags.length === 0 ? "Type and press Enter..." : ""} className="flex-1 min-w-[80px] text-xs outline-none bg-transparent" style={{ color: "var(--color-text)" }} />
); } function ReverseRelationCell({ links, sourceObjectName, onNavigateObject, onNavigateEntry }: { links: Array<{ id: string; label: string }>; sourceObjectName: string; onNavigateObject?: (objectName: string) => void; onNavigateEntry?: (objectName: string, entryId: string) => void; }) { if (!links || links.length === 0) {return --;} const display = links.slice(0, 5); const overflow = links.length - display.length; return ( {display.map((link) => ( { if (!onNavigateEntry && !onNavigateObject) {return;} e.stopPropagation(); if (onNavigateEntry) { onNavigateEntry(sourceObjectName, link.id); return; } onNavigateObject?.(sourceObjectName); }} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${onNavigateEntry || onNavigateObject ? "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, onNavigateObject, onNavigateEntry, onLocalValueChange, onSaved, }: { value: unknown; entryId: string; fieldName: string; objectName: string; field: Field; members?: Array<{ id: string; name: string }>; relationLabels?: Record>; onNavigateObject?: (objectName: string) => void; onNavigateEntry?: (objectName: string, entryId: string) => void; onLocalValueChange?: (value: 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 isTags = field.type === "tags"; const save = useCallback(async (val: string) => { onLocalValueChange?.(val); 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, onLocalValueChange, 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 (isTags) { return (
{ void save(v); }} autoFocus />
); } if (field.type === "enum" && field.enum_values) { editInput = ( ); } else if (field.type === "boolean") { editInput = ( ); } else { editInput = ( } type={inputTypeForField(field.type)} 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" >
); } // Tags fields: show tag chips with double-click to edit if (isTags) { 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" ? ( ) : ( )}
); } /* ─── Main ObjectTable ─── */ export function ObjectTable({ objectName, fields, entries, members, relationLabels, reverseRelations, onNavigateToObject, onNavigateToEntry, onEntryClick, onRefresh, columnVisibility, onColumnVisibilityChanged, serverPagination, onServerSearch, }: ObjectTableProps) { const [rowSelection, setRowSelection] = useState>({}); const [showAddModal, setShowAddModal] = useState(false); const [localEntries, setLocalEntries] = useState(entries as EntryRow[]); // Keep local rows aligned with server-paginated updates. useEffect(() => { setLocalEntries(entries as EntryRow[]); }, [entries]); const updateLocalEntryField = useCallback((entryId: string, fieldName: string, value: string) => { setLocalEntries((prev) => prev.map((entry) => { const eid = entry.entry_id; const currentEntryId = String( eid != null && typeof eid === "object" ? JSON.stringify(eid) : (eid ?? ""), ); if (currentEntryId !== entryId) {return entry;} return { ...entry, [fieldName]: value }; }), ); }, []); 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 ( updateLocalEntryField(entryId, field.name, value)} onSaved={onRefresh} /> ); }, size: field.type === "richtext" ? 300 : field.type === "relation" || field.type === "tags" ? 200 : 180, enableSorting: true, })); cols.push({ id: "created_at", accessorFn: (row) => resolveEntryMetaValue(row, CREATED_AT_KEYS), meta: { label: "Created", fieldName: "created_at" }, header: () => ( Created ), cell: (info: CellContext) => ( ), size: 190, enableSorting: true, }); cols.push({ id: "updated_at", accessorFn: (row) => resolveEntryMetaValue(row, UPDATED_AT_KEYS), meta: { label: "Updated", fieldName: "updated_at" }, header: () => ( Updated ), cell: (info: CellContext) => ( ), size: 190, 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, onNavigateToEntry, 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(localEntries[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, localEntries, 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[]) => { // Persist only real object field IDs (ignore synthetic/system columns). const fieldIdSet = new Set(fields.map((field) => field.id)); const fieldIds = newOrder.filter((id) => fieldIdSet.has(id)); 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, fields], ); // 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 === "tags" ? (
updateField(field.name, v)} />
) : field.type === "enum" && field.enum_values ? ( ) : field.type === "boolean" ? ( ) : field.type === "richtext" ? (