"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 && (
setOptionId(e.target.value)}
>
Any option…
{(property.config.options ?? []).map((o) => (
{o.name}
))}
)}
onApply(
textLike && text
? { propertyId: property.id, op: "contains", value: { text } }
: selectLike && optionId
? {
propertyId: property.id,
op: property.type === "multi_select" ? "contains_any" : "equals",
value: property.type === "multi_select" ? { optionIds: [optionId] } : { optionId }
}
: null
)
}
>
Apply
{
onApply({ propertyId: property.id, op: "is_empty", value: undefined });
}}
>
Empty
);
}
// ---------------------------------------------------------------------------
// 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 (
<>
setPropId(e.target.value)}
aria-label="Property to set"
>
{settable.map((p) => (
{p.name}
))}
{selectLike && (
setOptionId(e.target.value)}
aria-label="Value"
>
Value…
{(prop?.config.options ?? []).map((o) => (
{o.name}
))}
)}
{textLike && (
setText(e.target.value)}
/>
)}
{isNumber && (
setText(e.target.value)}
/>
)}
{isDate && (
setText(e.target.value)}
/>
)}
{isCheckbox ? "Check all" : "Set"}
>
);
}
// ---------------------------------------------------------------------------
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 && (
onOpenRecord(record)}>
void duplicateRecord(record)}>
onOpenRecord(record)}>
setConfirmDelete([record.id])}
>
)}
);
};
// ---- groups ------------------------------------------------------------------
const groups = data.groups;
return (
{/* header */}
{columns.map((p) => {
const dir = sortDirFor(p.id);
const width = view.config.columnWidths?.[p.id];
return (
cycleSort(p.id)}
onDoubleClick={() => {
if (!readOnly) setEditProperty(p);
}}
title={readOnly ? `Sort by ${p.name}` : `Sort by ${p.name} · double-click to rename`}
>
{propertyIcon(p.type)}
{p.name}
{dir && (
)}
{!readOnly && (
setQuickFilter(quickFilter === p.id ? null : p.id)}
title={`Filter ${p.name}`}
aria-label={`Filter ${p.name}`}
>
{quickFilter === p.id && (
{
onFilterChange(c);
setQuickFilter(null);
}}
onClear={() => {
onFilterChange(null);
setQuickFilter(null);
}}
/>
)}
)}
);
})}
{/* add property */}
{!readOnly && (
setAddPropertyOpen(true)}
title="Add property"
aria-label="Add property"
>
)}
{/* 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 && (
setAddPropertyOpen(true)}>
Add property
)}
) : (
{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 && (
onPageSizeChange(Number(e.target.value))}
>
{[20, 50, 100, 200].map((n) => (
{n}
))}
per page
)}
{!readOnly && (
Add row
)}
Prev
Next
)}
{/* bulk bar */}
{selected.length > 0 && !readOnly && (
{selected.length} selected
setConfirmDelete(selected)}
>
Delete
)}
{/* delete confirm */}
{confirmDelete && (
Delete {confirmDelete.length === 1 ? "this record" : `${confirmDelete.length} records`}?
The record and its page are hard-deleted. This cannot be undone.
setConfirmDelete(null)}>
Cancel
{
for (const id of confirmDelete) {
try {
await deleteRecord.mutateAsync(id);
} catch (e) {
notify({ title: "Delete failed", description: (e as Error).message, variant: "destructive" });
}
}
setConfirmDelete(null);
clearSelection();
notify({ title: `Deleted ${confirmDelete.length} record(s)` });
}}
disabled={deleteRecord.isPending}
>
Delete
)}
{/* 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] ?? "•"}
);
}