This commit refactors the DataTable and ObjectTable components to enhance state management. In DataTable, the column visibility state is now set more efficiently by defaulting to an empty object when no initial visibility is provided. In ObjectTable, local entries are introduced to maintain alignment with server updates, and a new callback for local value changes is added to EditableCell, improving the responsiveness of the UI during data edits. Additionally, the handling of row selection during bulk delete operations is updated to use local entries, ensuring consistency across the component's state.
851 lines
28 KiB
TypeScript
851 lines
28 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";
|
|
|
|
/* ─── 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;
|
|
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 };
|
|
|
|
/* ─── Helpers ─── */
|
|
|
|
/** Safely convert unknown (DuckDB) value to string for display. */
|
|
function safeString(val: unknown): string {
|
|
if (val == null) {return "";}
|
|
if (typeof val === "object") {return JSON.stringify(val);}
|
|
if (typeof val === "string") {return val;}
|
|
if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);}
|
|
// symbol, function
|
|
return "";
|
|
}
|
|
|
|
function parseRelationValue(value: string | null | undefined): string[] {
|
|
if (!value) {return [];}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {return [];}
|
|
if (trimmed.startsWith("[")) {
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
if (Array.isArray(parsed)) {return parsed.map(String).filter(Boolean);}
|
|
} catch { /* not JSON */ }
|
|
}
|
|
return [trimmed];
|
|
}
|
|
|
|
/* ─── Cell Renderers (read-only display) ─── */
|
|
|
|
function EnumBadge({ value, enumValues, enumColors }: { value: string; enumValues?: string[]; enumColors?: string[] }) {
|
|
const idx = enumValues?.indexOf(value) ?? -1;
|
|
const color = idx >= 0 && enumColors ? enumColors[idx] : "#94a3b8";
|
|
return (
|
|
<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, onNavigate,
|
|
}: {
|
|
value: unknown; field: Field;
|
|
relationLabels?: Record<string, Record<string, string>>;
|
|
onNavigate?: (objectName: 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 && onNavigate) { e.stopPropagation(); onNavigate(field.related_object_name); } }}
|
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${field.related_object_name && onNavigate ? "cursor-pointer" : ""}`}
|
|
style={{ background: "var(--color-chip-document)", color: "var(--color-chip-document-text)", border: "1px solid var(--color-border)" }}
|
|
>
|
|
<span className="truncate max-w-[180px]">{fieldLabels?.[id] ?? id}</span>
|
|
</span>
|
|
))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ReverseRelationCell({ links, sourceObjectName, onNavigate }: {
|
|
links: Array<{ id: string; label: string }>;
|
|
sourceObjectName: string;
|
|
onNavigate?: (objectName: 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) => { e.stopPropagation(); onNavigate?.(sourceObjectName); }}
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium cursor-pointer"
|
|
style={{ background: "var(--color-chip-database)", color: "var(--color-chip-database-text)", border: "1px solid var(--color-border)" }}
|
|
>
|
|
<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,
|
|
onNavigate,
|
|
onLocalValueChange,
|
|
onSaved,
|
|
}: {
|
|
value: unknown;
|
|
entryId: string;
|
|
fieldName: string;
|
|
objectName: string;
|
|
field: Field;
|
|
members?: Array<{ id: string; name: string }>;
|
|
relationLabels?: Record<string, Record<string, string>>;
|
|
onNavigate?: (objectName: 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={field.type === "number" ? "number" : field.type === "date" ? "date" : "text"}
|
|
value={localValue}
|
|
onChange={(e) => handleChange(e.target.value)}
|
|
onBlur={handleBlur}
|
|
onKeyDown={handleKeyDown}
|
|
className="w-full text-xs outline-none bg-transparent"
|
|
style={{ color: "var(--color-text)" }}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<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} onNavigate={onNavigate} />
|
|
</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} />
|
|
) : field.type === "email" ? (
|
|
<a href={`mailto:${safeString(displayValue)}`} className="underline underline-offset-2" style={{ color: "var(--color-accent)" }} onClick={(e) => e.stopPropagation()}>
|
|
{safeString(displayValue)}
|
|
</a>
|
|
) : field.type === "number" ? (
|
|
<span className="tabular-nums">{safeString(displayValue)}</span>
|
|
) : (
|
|
<span className="truncate block max-w-[300px]">{safeString(displayValue)}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── Main ObjectTable ─── */
|
|
|
|
export function ObjectTable({
|
|
objectName,
|
|
fields,
|
|
entries,
|
|
members,
|
|
relationLabels,
|
|
reverseRelations,
|
|
onNavigateToObject,
|
|
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}
|
|
onNavigate={onNavigateToObject}
|
|
onLocalValueChange={(value) => updateLocalEntryField(entryId, field.name, value)}
|
|
onSaved={onRefresh}
|
|
/>
|
|
);
|
|
},
|
|
size: field.type === "richtext" ? 300 : field.type === "relation" ? 200 : 180,
|
|
enableSorting: true,
|
|
}));
|
|
|
|
// Add reverse relation columns
|
|
for (const rr of activeReverseRelations) {
|
|
cols.push({
|
|
id: `rev_${rr.sourceObjectName}_${rr.fieldName}`,
|
|
meta: { label: `${rr.sourceObjectName} (via ${rr.fieldName})` },
|
|
header: () => (
|
|
<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} onNavigate={onNavigateToObject} />;
|
|
},
|
|
enableSorting: false,
|
|
size: 200,
|
|
});
|
|
}
|
|
|
|
return cols;
|
|
}, [fields, activeReverseRelations, objectName, members, relationLabels, onNavigateToObject, onRefresh]);
|
|
|
|
// Add entry handler — opens modal instead of creating empty entry
|
|
const handleAdd = useCallback(() => {
|
|
setShowAddModal(true);
|
|
}, []);
|
|
|
|
// Bulk delete handler
|
|
const handleBulkDelete = useCallback(async () => {
|
|
const selectedIds = Object.keys(rowSelection)
|
|
.filter((k) => rowSelection[k])
|
|
.map((idx) => safeString(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[]) => {
|
|
// Map column IDs back to field IDs (exclude select, actions, and reverse relations)
|
|
const fieldIds = newOrder.filter((id) => !id.startsWith("rev_") && id !== "select" && id !== "actions");
|
|
try {
|
|
await fetch(`/api/workspace/objects/${encodeURIComponent(objectName)}/fields/reorder`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fieldOrder: fieldIds }),
|
|
});
|
|
} catch { /* ignore */ }
|
|
},
|
|
[objectName],
|
|
);
|
|
|
|
// Bulk actions toolbar
|
|
const bulkActions = (
|
|
<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={field.type === "number" ? "number" : field.type === "date" ? "date" : field.type === "email" ? "email" : "text"}
|
|
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>
|
|
);
|
|
}
|