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.
949 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|