kumarabhirup 3533aa3358
feat(web): persist file tree expansion and add column visibility callbacks
Save expanded file tree paths to localStorage so they survive page reloads, and surface an onColumnVisibilityChanged callback from DataTable/ObjectTable.
2026-03-05 19:09:34 -08:00

952 lines
30 KiB
TypeScript

"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";
/* ─── 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<string, Array<{ id: string; label: string }>>;
};
type ServerPaginationProps = {
totalCount: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
};
type ObjectTableProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
members?: Array<{ id: string; name: string }>;
relationLabels?: Record<string, Record<string, string>>;
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<string, boolean>;
onColumnVisibilityChanged?: (visibility: Record<string, boolean>) => void;
/** Server-side pagination props. */
serverPagination?: ServerPaginationProps;
/** Server-side search callback. */
onServerSearch?: (query: string) => void;
};
type EntryRow = Record<string, unknown> & { 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<string, unknown>,
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 (
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
style={{ background: `${color}20`, color, border: `1px solid ${color}40` }}
>
{value}
</span>
);
}
function BooleanCell({ value }: { value: unknown }) {
const isTrue = value === true || value === "true" || value === "1" || value === "yes";
return (
<span style={{ color: isTrue ? "var(--color-success)" : "var(--color-text-muted)" }}>
{isTrue ? "Yes" : "No"}
</span>
);
}
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 (
<span className="flex items-center gap-1.5">
<span className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-medium flex-shrink-0" style={{ background: "var(--color-accent)", color: "white" }}>
{(member?.name ?? memberId).charAt(0).toUpperCase()}
</span>
<span className="truncate">{member?.name ?? memberId}</span>
</span>
);
}
function RelationCell({
value, field, relationLabels, onNavigateObject, onNavigateEntry,
}: {
value: unknown; field: Field;
relationLabels?: Record<string, Record<string, string>>;
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 <span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>;}
return (
<span className="flex items-center gap-1 flex-wrap">
{ids.map((id) => (
<span
key={id}
onClick={(e) => {
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)" }}
>
<span className="truncate max-w-[180px]">{fieldLabels?.[id] ?? id}</span>
</span>
))}
</span>
);
}
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 <span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>;}
const display = links.slice(0, 5);
const overflow = links.length - display.length;
return (
<span className="flex items-center gap-1 flex-wrap">
{display.map((link) => (
<span
key={link.id}
onClick={(e) => {
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)" }}
>
<span className="truncate max-w-[180px]">{link.label}</span>
</span>
))}
{overflow > 0 && <span className="text-xs" style={{ color: "var(--color-text-muted)" }}>+{overflow}</span>}
</span>
);
}
/* ─── 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<string, Record<string, string>>;
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<HTMLInputElement | HTMLSelectElement>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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) => {
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 <UserCell value={initialValue} members={members} />;}
return <span className="truncate block max-w-[300px]">{safeString(initialValue)}</span>;
}
// 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={safeString(initialValue)}
multiple={field.relationship_type === "many_to_many"}
onChange={(v) => { void save(v); setEditing(false); }}
variant="inline"
autoFocus
/>
</div>
);
}
if (field.type === "enum" && field.enum_values) {
editInput = (
<select
ref={inputRef as React.RefObject<HTMLSelectElement>}
value={localValue}
onChange={(e) => { handleChange(e.target.value); setEditing(false); }}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-full text-xs outline-none bg-transparent"
style={{ color: "var(--color-text)" }}
>
<option value="">--</option>
{field.enum_values.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
);
} else if (field.type === "boolean") {
editInput = (
<select
ref={inputRef as React.RefObject<HTMLSelectElement>}
value={localValue}
onChange={(e) => { handleChange(e.target.value); setEditing(false); }}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-full text-xs outline-none bg-transparent"
style={{ color: "var(--color-text)" }}
>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
} else {
editInput = (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
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 (
<div
className="-mx-3 -my-2 px-3 py-2"
style={{
background: "var(--color-bg)",
boxShadow: "inset 0 0 0 2px var(--color-accent)",
}}
>
{editInput}
</div>
);
}
// 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}
onNavigateObject={onNavigateObject}
onNavigateEntry={onNavigateEntry}
/>
</div>
);
}
return (
<div
onDoubleClick={() => setEditing(true)}
className="cursor-cell min-h-[1.5em]"
title="Double-click to edit"
>
{displayValue === null || displayValue === undefined || displayValue === "" ? (
<span style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>--</span>
) : field.type === "enum" ? (
<EnumBadge value={safeString(displayValue)} enumValues={field.enum_values} enumColors={field.enum_colors} />
) : field.type === "boolean" ? (
<BooleanCell value={displayValue} />
) : (
<FormattedFieldValue value={displayValue} fieldType={field.type} mode="table" />
)}
</div>
);
}
/* ─── Main ObjectTable ─── */
export function ObjectTable({
objectName,
fields,
entries,
members,
relationLabels,
reverseRelations,
onNavigateToObject,
onNavigateToEntry,
onEntryClick,
onRefresh,
columnVisibility,
onColumnVisibilityChanged,
serverPagination,
onServerSearch,
}: ObjectTableProps) {
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
const [showAddModal, setShowAddModal] = useState(false);
const [localEntries, setLocalEntries] = useState<EntryRow[]>(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<ColumnDef<EntryRow>[]>(() => {
const cols: ColumnDef<EntryRow>[] = fields.map((field, fieldIdx) => ({
id: field.id,
accessorKey: field.name,
meta: { label: field.name, fieldName: field.name },
header: () => (
<span className="flex items-center gap-1" style={{ color: "var(--color-text-muted)" }}>
{field.name}
{field.type === "relation" && field.related_object_name && (
<span className="text-[9px] font-normal normal-case tracking-normal opacity-60">
({field.related_object_name})
</span>
)}
</span>
),
cell: (info: CellContext<EntryRow, unknown>) => {
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 (
<span
className={`font-semibold truncate block max-w-[300px] ${isEmpty ? "" : "cursor-pointer hover:underline"}`}
style={{ color: isEmpty ? "var(--color-text-muted)" : "var(--color-accent)", opacity: isEmpty ? 0.5 : 1 }}
onClick={(e) => {
e.stopPropagation();
if (entryId && !isEmpty) {onEntryClick(entryId);}
}}
>
{displayVal}
</span>
);
}
return (
<EditableCell
value={info.getValue()}
entryId={entryId}
fieldName={field.name}
objectName={objectName}
field={field}
members={members}
relationLabels={relationLabels}
onNavigateObject={onNavigateToObject}
onNavigateEntry={onNavigateToEntry}
onLocalValueChange={(value) => updateLocalEntryField(entryId, field.name, value)}
onSaved={onRefresh}
/>
);
},
size: field.type === "richtext" ? 300 : field.type === "relation" ? 200 : 180,
enableSorting: true,
}));
cols.push({
id: "created_at",
accessorFn: (row) => resolveEntryMetaValue(row, CREATED_AT_KEYS),
meta: { label: "Created", fieldName: "created_at" },
header: () => (
<span className="flex items-center gap-1" style={{ color: "var(--color-text-muted)" }}>
Created
</span>
),
cell: (info: CellContext<EntryRow, unknown>) => (
<FormattedFieldValue value={info.getValue()} fieldType="date" mode="table" />
),
size: 190,
enableSorting: true,
});
cols.push({
id: "updated_at",
accessorFn: (row) => resolveEntryMetaValue(row, UPDATED_AT_KEYS),
meta: { label: "Updated", fieldName: "updated_at" },
header: () => (
<span className="flex items-center gap-1" style={{ color: "var(--color-text-muted)" }}>
Updated
</span>
),
cell: (info: CellContext<EntryRow, unknown>) => (
<FormattedFieldValue value={info.getValue()} fieldType="date" mode="table" />
),
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: () => (
<span className="flex items-center gap-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">{rr.sourceObjectName}</span>
<span className="text-[9px] font-normal normal-case tracking-normal opacity-50">via {rr.fieldName}</span>
</span>
),
cell: (info: CellContext<EntryRow, unknown>) => {
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 (
<ReverseRelationCell
links={links}
sourceObjectName={rr.sourceObjectName}
onNavigateObject={onNavigateToObject}
onNavigateEntry={onNavigateToEntry}
/>
);
},
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<EntryRow>[] => {
const actions: RowAction<EntryRow>[] = [];
if (onEntryClick) {
actions.push({
label: "View details",
onClick: (r) => {
const eid = String(r.entry_id ?? "");
if (eid) {onEntryClick(eid);}
},
icon: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" /><circle cx="12" cy="12" r="3" /></svg>,
});
}
actions.push({
label: "Delete",
variant: "destructive",
onClick: handleDeleteEntry,
icon: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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>,
});
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 = (
<button
type="button"
onClick={() => void handleBulkDelete()}
className="flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium"
style={{ background: "rgba(220, 38, 38, 0.08)", color: "var(--color-error)", border: "1px solid rgba(220, 38, 38, 0.2)" }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><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>
Delete
</button>
);
return (
<>
<DataTable
columns={columns}
data={localEntries}
enableSorting
enableGlobalFilter
enableRowSelection
enableColumnReordering
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
bulkActions={bulkActions}
onColumnReorder={handleColumnReorder}
searchPlaceholder={`Search ${objectName}...`}
onRefresh={onRefresh}
onAdd={handleAdd}
addButtonLabel="+ Add"
rowActions={getRowActions}
stickyFirstColumn
initialColumnVisibility={columnVisibility}
onColumnVisibilityChanged={onColumnVisibilityChanged}
serverPagination={serverPagination}
onServerSearch={onServerSearch}
/>
{/* Add Entry Modal */}
{showAddModal && (
<AddEntryModal
objectName={objectName}
fields={fields}
members={members}
onClose={() => 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<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const backdropRef = useRef<HTMLDivElement>(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 (
<div
ref={backdropRef}
onClick={(e) => { 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)" }}
>
<div
className="relative mt-12 mb-12 w-full max-w-lg rounded-2xl overflow-hidden shadow-2xl flex flex-col"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
maxHeight: "calc(100vh - 6rem)",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<h2 className="text-lg font-semibold capitalize" style={{ color: "var(--color-text)" }}>
Add {objectName}
</h2>
<button type="button" onClick={onClose} className="p-1.5 rounded-lg" style={{ color: "var(--color-text-muted)" }}>
<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>
{/* Form */}
<form
onSubmit={(e) => { 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 (
<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}
{isRelation && field.related_object_name && (
<span className="normal-case tracking-normal font-normal opacity-60 ml-1">
({field.related_object_name})
</span>
)}
</label>
{field.type === "enum" && field.enum_values ? (
<select
value={values[field.name] ?? ""}
onChange={(e) => updateField(field.name, e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg outline-none"
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
>
<option value="">-- Select --</option>
{field.enum_values.map((v) => (
<option key={v} value={v}>{v}</option>
))}
</select>
) : field.type === "boolean" ? (
<select
value={values[field.name] ?? ""}
onChange={(e) => updateField(field.name, e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg outline-none"
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
>
<option value="">-- Select --</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
) : field.type === "richtext" ? (
<textarea
value={values[field.name] ?? ""}
onChange={(e) => updateField(field.name, e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm rounded-lg outline-none resize-none"
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
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] ?? ""}
onChange={(e) => updateField(field.name, e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg outline-none"
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
>
<option value="">-- Select member --</option>
{members?.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
) : (
<input
type={inputTypeForField(field.type)}
value={values[field.name] ?? ""}
onChange={(e) => updateField(field.name, e.target.value)}
className="w-full px-3 py-2 text-sm rounded-lg outline-none"
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
placeholder={field.name}
/>
)}
</div>
);
})}
</form>
{/* Footer */}
<div
className="flex items-center justify-end gap-2 px-6 py-4 border-t flex-shrink-0"
style={{ borderColor: "var(--color-border)" }}
>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-lg"
style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}
>
Cancel
</button>
<button
type="button"
onClick={() => void handleSave()}
disabled={saving}
className="px-4 py-2 text-sm font-medium rounded-lg"
style={{ background: "var(--color-accent)", color: "white", opacity: saving ? 0.7 : 1 }}
>
{saving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
);
}