"use client"; /** * TableView — grid renderer for TABLE (and fallback) views. * * "Virtualized-ish": renders pages of 50 rows with a "Show more" / prev-next * cursor control (the documented pagination), sticky header, row selection * with a bulk bar, group headers when the view has a group, per-column * quick filters, and inline cell editing via . */ import { useCallback, useMemo, useRef, useState } from "react"; import { ChevronDown, ChevronLeft, ChevronRight, Copy, Eye, Filter, MessageSquare, Plus, Sparkles, Trash2, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiFetch } from "@/lib/api"; import { toast } from "@/stores/ui-store"; /** Toast adapter: call sites use variant:"destructive"; core store uses kind. */ const notify = (t: { title: string; description?: string; variant?: string }) => toast(t.variant === "destructive" ? "error" : "success", t.title, t.description); import { useDatabaseStore } from "@/stores/database-store"; import { DatabaseProperty, DatabaseView, FilterCondition, RecordDto, RecordValue, SortSpec, UseDatabaseRecordsResult, useBulkUpdateRecords, useCreateRecord, useDeleteRecord, valueToText } from "@/hooks/use-database"; import { CellRenderer, isEditableProperty } from "@/components/database/cell-renderer"; import { CellEditor } from "@/components/database/cell-editor"; import { PropertyEditor } from "@/components/database/property-editor"; export interface ViewCommonProps { databaseId: string; view: DatabaseView; properties: DatabaseProperty[]; records: UseDatabaseRecordsResult; /** Open the right-hand record panel. */ onOpenRecord: (record: RecordDto) => void; /** Navigate to a record page. */ onOpenRecordPage: (record: RecordDto) => void; /** Ad-hoc sort override change (view tabs + headers feed this). */ onSortChange: (sort: SortSpec[] | null) => void; /** Ad-hoc filter override change (column quick filter). */ onFilterChange: (filter: FilterCondition | FilterCondition[] | null) => void; /** T-166: persist the rows-per-page on the view (`view.pageSize`). */ onPageSizeChange?: (pageSize: number) => void; readOnly?: boolean; } const PAGE_SIZE_WINDOW = 50; // rows per cursor page (matches records hook default) function visibleColumns(view: DatabaseView, properties: DatabaseProperty[]): DatabaseProperty[] { const visible = view.config.visibleProperties; const cols = visible ? visible .map((id) => properties.find((p) => p.id === id)) .filter((p): p is DatabaseProperty => !!p) : properties; // Title first, keep declared order for the rest. const title = cols.find((p) => p.isTitle); const rest = cols.filter((p) => p !== title); return title ? [title, ...rest] : cols; } // --------------------------------------------------------------------------- // Column quick filter popover // --------------------------------------------------------------------------- function ColumnQuickFilter({ property, onApply, onClear }: { property: DatabaseProperty; onApply: (c: FilterCondition | null) => void; onClear: () => void; }) { const [text, setText] = useState(""); const [optionId, setOptionId] = useState(""); const selectLike = property.type === "select" || property.type === "status" || property.type === "multi_select"; const textLike = ["title", "text", "rich_text", "email", "url"].includes(property.type); return (

Filter: {property.name}

{textLike && ( setText(e.target.value)} /> )} {selectLike && ( )}
); } // --------------------------------------------------------------------------- // Bulk bar value input (set one property on all selected rows) // --------------------------------------------------------------------------- function BulkSetBar({ databaseId, columns, records, selected }: { databaseId: string; columns: DatabaseProperty[]; records: RecordDto[]; selected: string[]; }) { const clearSelection = useDatabaseStore((s) => s.clearSelection); const bulk = useBulkUpdateRecords(databaseId); const settable = columns.filter((p) => isEditableProperty(p)); const [propId, setPropId] = useState(settable.find((p) => p.type === "select" || p.type === "status")?.id ?? settable[0]?.id ?? ""); const [text, setText] = useState(""); const [optionId, setOptionId] = useState(""); const prop = columns.find((p) => p.id === propId); const selectLike = prop?.type === "select" || prop?.type === "status"; const textLike = prop && ["title", "text", "rich_text", "email", "url", "phone"].includes(prop.type); const isNumber = prop?.type === "number"; const isDate = prop?.type === "date"; const isCheckbox = prop?.type === "checkbox"; const canApply = selectLike ? !!optionId : isCheckbox ? true : !!text; const apply = () => { if (!prop) return; const value: RecordValue = selectLike ? { optionId } : isCheckbox ? { value: true } : isNumber ? { value: Number(text) } : isDate ? { start: new Date(text).toISOString(), end: null } : { text, marks: [] }; const items = records .filter((r) => selected.includes(r.id)) .map((r) => ({ recordId: r.id, properties: { [prop.id]: value }, version: r.version })); bulk.mutate( items, { onSuccess: (res) => { clearSelection(); notify({ title: `Updated ${res.succeeded} record(s)`, description: res.failed.length ? `${res.failed.length} failed` : undefined, variant: res.failed.length ? "destructive" : "default" }); setText(""); setOptionId(""); }, onError: (e) => notify({ title: "Bulk update failed", description: (e as Error).message, variant: "destructive" }) } ); }; return ( <> {selectLike && ( )} {textLike && ( setText(e.target.value)} /> )} {isNumber && ( setText(e.target.value)} /> )} {isDate && ( setText(e.target.value)} /> )} ); } // --------------------------------------------------------------------------- export function TableView(props: ViewCommonProps) { const { databaseId, view, properties, records, onOpenRecord, onOpenRecordPage, onSortChange, onFilterChange, onPageSizeChange, readOnly } = props; const { data, isLoading, isFetching, hasMore, loadMore, goPrev, canPrev } = records; const columns = useMemo(() => visibleColumns(view, properties), [view, properties]); const selected = useDatabaseStore((s) => s.selectedRecordIds); const setSelected = useDatabaseStore((s) => s.setSelectedRecords); const toggleSelected = useDatabaseStore((s) => s.toggleSelectedRecord); const clearSelection = useDatabaseStore((s) => s.clearSelection); const [editingCell, setEditingCell] = useState<{ recordId: string; propertyId: string } | null>(null); const [addPropertyOpen, setAddPropertyOpen] = useState(false); // Double-click a column header → the property editor in EDIT mode (rename, // options, visibility, delete). The editor's edit mode existed but nothing // ever opened it (the + Property button is add-only), so columns could not // be renamed from the UI even though PATCH /properties/:id works. const [editProperty, setEditProperty] = useState(null); const [quickFilter, setQuickFilter] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const headerRef = useRef(null); const createRecord = useCreateRecord(databaseId); const deleteRecord = useDeleteRecord(databaseId); const titleProp = columns.find((p) => p.isTitle) ?? columns[0]; // ---- sort cycle (asc → desc → none) for a column ------------------------- const cycleSort = (propertyId: string) => { const sorts = view.sort ?? []; const idx = sorts.findIndex((s) => s.propertyId === propertyId); const current = sorts[idx]; let next: SortSpec; if (!current) next = { propertyId, direction: "asc" }; else if (current.direction === "asc") next = { propertyId, direction: "desc" }; else next = { propertyId, direction: "asc" as const }; const rest = sorts.filter((s) => s.propertyId !== propertyId); if (!current || current.direction === "desc") { onSortChange([...rest, next]); } else if (current.direction === "asc" && next.direction === "desc") { onSortChange([...rest, next]); } else { onSortChange(rest.length ? rest : null); } }; // ---- row actions ----------------------------------------------------------- const duplicateRecord = async (record: RecordDto) => { if (!record.pageId) return; try { await apiFetch(`/pages/${record.pageId}/duplicate`, { method: "POST", body: JSON.stringify({}) }); notify({ title: "Record duplicated" }); } catch (e) { notify({ title: "Duplicate failed", description: (e as Error).message, variant: "destructive" }); } }; const addRow = () => { // The API rejects the title inside `properties` (`title_via_title_field`) // — it must go in the top-level `title` field (docs/api/databases.md §7). createRecord.mutate( { title: "New record", properties: {} }, { onSuccess: () => notify({ title: "Row added" }), onError: (e) => notify({ title: "Add row failed", description: (e as Error).message, variant: "destructive" }) } ); }; // ---- selection helpers ----------------------------------------------------- const allVisibleIds = data.records.map((r) => r.id); const allSelected = allVisibleIds.length > 0 && allVisibleIds.every((id) => selected.includes(id)); const toggleAll = () => setSelected(allSelected ? [] : allVisibleIds); const sortDirFor = (propertyId: string): "asc" | "desc" | null => { const s = view.sort?.find((x) => x.propertyId === propertyId); return s?.direction ?? null; }; const renderRow = (record: RecordDto) => { const isSelected = selected.includes(record.id); return (
{/* checkbox */}
toggleSelected(record.id)} aria-label={`Select ${recordTitleSafe(record, properties)}`} onClick={(e) => e.stopPropagation()} />
{/* cells */} {columns.map((p) => { const isEditing = editingCell?.recordId === record.id && editingCell?.propertyId === p.id; const width = view.config.columnWidths?.[p.id]; return (
{isEditing ? ( setEditingCell(null)} className="min-w-full" /> ) : ( setEditingCell({ recordId: r.id, propertyId: prop.id }) } onOpenRecord={onOpenRecordPage} /> )}
); })} {/* hover actions */} {!readOnly && (
)}
); }; // ---- groups ------------------------------------------------------------------ const groups = data.groups; return (
{/* header */}
{columns.map((p) => { const dir = sortDirFor(p.id); const width = view.config.columnWidths?.[p.id]; return (
{!readOnly && (
{quickFilter === p.id && (
{ onFilterChange(c); setQuickFilter(null); }} onClear={() => { onFilterChange(null); setQuickFilter(null); }} />
)}
)}
); })} {/* add property */} {!readOnly && (
)}
{/* body */}
{isLoading ? (
{Array.from({ length: 8 }).map((_, i) => (
))}
) : data.records.length === 0 ? (

{properties.length === 0 ? "This database has no properties yet." : "No records match this view."}

{properties.length > 0 && !readOnly && (
)} {properties.length === 0 && !readOnly && ( )}
) : (
{isFetching &&
} {groups ? groups.map((g) => (
{g.key.label} ({g.count})
{g.records.map(renderRow)}
)) : data.records.map(renderRow)}
)} {/* footer: count + add-row + pagination (cursor prev / next) */} {!isLoading && data.records.length > 0 && (
{data.records.length} of {data.totalCount ?? "…"} records {/* T-166: explicit rows-per-page (persisted on the view). The query hook refetches from page 1 when `pageSize` changes (it is part of the query key). */} {onPageSizeChange && ( )}
{!readOnly && ( )}
)}
{/* bulk bar */} {selected.length > 0 && !readOnly && (
{selected.length} selected
)} {/* delete confirm */} {confirmDelete && (

Delete {confirmDelete.length === 1 ? "this record" : `${confirmDelete.length} records`}?

The record and its page are hard-deleted. This cannot be undone.

)} {/* add property dialog */} {addPropertyOpen && ( setAddPropertyOpen(false)} /> )} {/* edit property dialog (double-clicked a column header) */} {editProperty && ( setEditProperty(null)} /> )}
); } // --------------------------------------------------------------------------- // Small helpers // --------------------------------------------------------------------------- function gridTemplate(columns: DatabaseProperty[]): string { const widths = columns.map((p) => { const w = 160; return `${w}px`; }); return `28px ${widths.join(" ")} 32px`; } function recordTitleSafe(record: RecordDto, properties: DatabaseProperty[]): string { const t = properties.find((p) => p.isTitle); if (!t) return record.id.slice(-6); return valueToText(record.properties[t.id], t) || "Untitled"; } function titlePropertySafe(properties: DatabaseProperty[]) { return properties.find((p) => p.isTitle) ?? properties.find((p) => p.type === "title"); } function propertyIcon(type: string): React.ReactNode { const map: Record = { title: "T", text: "A", rich_text: "¶", number: "#", select: "◉", multi_select: "◌", status: "●", date: "📅", date_range: "🗓", person: "👤", email: "@", phone: "✆", url: "🔗", checkbox: "☑", files: "📎", created_time: "🕐", created_by: "👤", last_edited_time: "🕓", last_edited_by: "👤", relation: "↔", rollup: "Σ", formula: "ƒ", button: "⚡", unique_id: "ID" }; return ( {map[type] ?? "•"} ); }