kumarabhirup 68015d6c14
refactor(web): enhance entry detail and object table components for improved data handling
This commit introduces several enhancements across the EntryDetailModal and ObjectTable components. Key changes include the addition of a FormattedFieldValue component for consistent display of various field types, improved handling of entry metadata, and the introduction of input type resolution for fields. Additionally, navigation callbacks for entries have been refined to support better interaction within the object table. These updates aim to streamline data presentation and enhance user experience.
2026-03-03 22:54:12 -08:00

949 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>;
/** 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,
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}
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>
);
}