"use client"; /** * BoardView — kanban renderer for BOARD views (dnd-kit). * * Columns come from view.group (select/status/multi_select/person/date — * enforced by the server, see GroupBuilder). Cards show the title + up to 2 * secondary properties as chips (view.config.cardProperties). * * Interactions: * - drag a card between columns → PATCH the record's group property * (optimistic via useUpdateRecord, rollback on 409/error); * - drag within a column → PATCH position (fractional, via the * two-neighbor rule in docs/api/databases.md §7.2); * - "+" on a column header → create a record in that group; * - card → open the record panel; * - column header quick filter → ad-hoc filter override (contains). */ import { useMemo, useState } from "react"; import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, type CollisionDetection } from "@dnd-kit/core"; import { ChevronDown, Filter, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { DatabaseProperty, DatabaseView, RecordDto, RecordGroup, RecordValue, UseDatabaseRecordsResult, useCreateRecord, useUpdateRecord, valueToText } from "@/hooks/use-database"; import { cellToText } from "@/components/database/cell-renderer"; export interface BoardProps { databaseId: string; view: DatabaseView; properties: DatabaseProperty[]; records: UseDatabaseRecordsResult; onOpenRecord: (record: RecordDto) => void; readOnly?: boolean; } type ColumnKey = string; // JSON of the group value const NO_GROUP_KEY = "__no_group__"; function columnKey(g: RecordGroup): ColumnKey { return JSON.stringify(g.key.value); } /** Value stored on the group property for a column (null = "No group"). */ function groupValueForColumn(key: ColumnKey, groupProperty: DatabaseProperty): RecordValue { const v = JSON.parse(key) as RecordValue | string | number; // Date groups: the key may arrive as a bare ISO string. if (groupProperty.type === "date" && typeof v === "string") { return { start: v, end: null }; } const value = (v ?? {}) as RecordValue; if (groupProperty.type === "multi_select") { return { optionIds: [value.optionId].filter(Boolean) as string[] }; } return value; } // --------------------------------------------------------------------------- // Card // --------------------------------------------------------------------------- function CardContent({ record, titleProp, props, compact }: { record: RecordDto; titleProp?: DatabaseProperty; props: DatabaseProperty[]; compact?: boolean }) { const chips = (viewConfigCardProperties(props, titleProp)).slice(0, 2); return (

{record.icon ? `${record.icon} ` : ""} {titleProp ? valueToText(record.properties[titleProp.id], titleProp) || "Untitled" : record.id.slice(-6)}

{!compact && (
{chips.map((p) => { const text = cellToText(record.properties[p.id], p); if (!text) return null; return ( {p.name}: {text} ); })}
)}
); } function viewConfigCardProperties(props: DatabaseProperty[], titleProp?: DatabaseProperty): DatabaseProperty[] { // Non-title properties; cardProperties is honored by the parent view config // (kept simple here: first two non-title visible properties). return props.filter((p) => p !== titleProp && p.type !== "relation" && p.type !== "files").slice(0, 2); } function DraggableCard({ record, titleProp, props, onOpen, disabled }: { record: RecordDto; titleProp?: DatabaseProperty; props: DatabaseProperty[]; onOpen: (r: RecordDto) => void; disabled?: boolean }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: record.id, disabled }); return (
onOpen(record)} {...listeners} {...attributes} >
); } // --------------------------------------------------------------------------- // Column // --------------------------------------------------------------------------- function BoardColumn({ group, groupProperty, titleProp, properties, onCreate, onOpen, draggableDisabled, quickFilter, onQuickFilter, droppableId }: { group: RecordGroup | null; // null = empty "No group" groupProperty: DatabaseProperty; titleProp?: DatabaseProperty; properties: DatabaseProperty[]; onCreate: (value: RecordValue | null) => void; onOpen: (r: RecordDto) => void; draggableDisabled: boolean; quickFilter: boolean; onQuickFilter: (v: boolean) => void; /** dnd droppable id (defaults to the column key). */ droppableId?: string; }) { const [collapsed, setCollapsed] = useState(false); const { setNodeRef, isOver } = useDroppable({ id: droppableId ?? columnKey(group) }); const records = group?.records ?? []; const label = group?.key.label ?? "No group"; return (
{label} {records.length}
{collapsed ? null : (
{records.map((r) => ( ))} {records.length === 0 && (

{quickFilter ? "No matching cards" : "Drop cards here"}

)}
)}
); } // --------------------------------------------------------------------------- const kanbanCollision: CollisionDetection = (args) => { // Prefer the droppable column under the pointer; fall back to rectIntersection. const pointer = args.pointerCoordinates; for (const { droppable } of args.droppableContainers) { const box = droppable.rect.current.translated; if (box && pointer.x >= box.left && pointer.x <= box.right && pointer.y >= box.top && pointer.y <= box.bottom) { return [droppable.id as unknown as { id: string }]; } } return []; }; export function BoardView({ databaseId, view, properties, records, onOpenRecord, readOnly }: BoardProps) { const { data, isLoading } = records; const updateRecord = useUpdateRecord(databaseId); const createRecord = useCreateRecord(databaseId); const groupSpec = view.group?.[0]; const groupProperty = groupSpec ? properties.find((p) => p.id === groupSpec.propertyId) : undefined; const titleProp = properties.find((p) => p.isTitle); const [activeId, setActiveId] = useState(null); const [quickFilterOn, setQuickFilterOn] = useState(false); const [quickText, setQuickText] = useState(""); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); // ---- groups + "No group" bucket ------------------------------------------ const groups = useMemo(() => { if (!groupProperty) return { list: [] as RecordGroup[], noGroup: [] as RecordDto[] }; const byKey = new Map(); for (const g of data.groups ?? []) { if (g.key.propertyId !== groupProperty.id) continue; byKey.set(columnKey(g), g); } // Records whose group value is empty land in "No group". const noGroup: RecordDto[] = []; for (const r of data.records) { if (isGroupValueEmpty(r.properties[groupProperty.id], groupProperty)) noGroup.push(r); } return { list: Array.from(byKey.values()), noGroup }; }, [data, groupProperty]); // Quick filter (contains on title) — purely visual, over the loaded pages. const quickFilterActive = quickFilterOn && quickText.trim().length > 0; const effectiveGroups = useMemo(() => { if (!quickFilterActive) return groups; const t = quickText.trim().toLowerCase(); const match = (r: RecordDto) => (titleProp ? valueToText(r.properties[titleProp.id], titleProp) : "").toLowerCase().includes(t); const list = groups.list.map((g) => ({ ...g, records: g.records.filter(match), count: g.records.filter(match).length })); return { ...groups, list, noGroup: groups.noGroup.filter(match) }; }, [groups, quickFilterActive, quickText, titleProp]); // ---- drag & drop ----------------------------------------------------------- const onDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id)); const onDragEnd = (e: DragEndEvent) => { setActiveId(null); if (readOnly || !e.over || !groupProperty) return; const overKey = String(e.over.id) as ColumnKey; const recordId = String(e.active.id); const allRecords = effectiveGroups.list.flatMap((g) => g.records).concat(effectiveGroups.noGroup); const record = allRecords.find((r) => r.id === recordId); if (!record) return; // Current column key for this card (No group bucket = NO_GROUP_KEY). const inList = effectiveGroups.list.find((g) => g.records.some((r) => r.id === recordId)); const currentKey = inList ? columnKey(inList) : NO_GROUP_KEY; if (currentKey === overKey) return; // same column — group unchanged // Target stored value: the column's group value, or empty to clear. const target: RecordValue = overKey === NO_GROUP_KEY ? { optionId: null as unknown as string, optionIds: [], userIds: [], start: null } : groupValueForColumn(overKey, groupProperty); if (!isGroupValueEmpty(target, groupProperty)) { target.optionId = target.optionId ?? null; } // Optimistic PATCH of the group property; useUpdateRecord rolls back on // 409/version conflict or network error. updateRecord.mutate({ recordId, version: record.version, properties: { [groupProperty.id]: target } }); }; const createInGroup = (groupKey: ColumnKey) => { if (!groupProperty) return; if (groupKey === NO_GROUP_KEY) { createRecord.mutate({ properties: {} }); return; } createRecord.mutate({ properties: { [groupProperty.id]: groupValueForColumn(groupKey, groupProperty) } }); }; const activeRecord = activeId ? effectiveGroups.list.flatMap((g) => g.records).concat(effectiveGroups.noGroup).find((r) => r.id === activeId) : null; if (!groupProperty) { return (
This board view has no group. Open the view's Group settings and pick a select, status, multi-select, person or date property.
); } return (
{effectiveGroups.list.map((g) => ( createInGroup(columnKey(g))} onOpen={onOpenRecord} draggableDisabled={!!readOnly} quickFilter={quickFilterOn} onQuickFilter={setQuickFilterOn} /> ))} {/* No group column */}
{quickFilterOn && ( setQuickText(e.target.value)} aria-label="Quick filter cards" /> )} createInGroup(NO_GROUP_KEY)} onOpen={onOpenRecord} draggableDisabled={!!readOnly} quickFilter={false} onQuickFilter={() => undefined} droppableId={NO_GROUP_KEY} />
{activeRecord ? ( ) : null}
); } function isGroupValueEmpty(v: RecordValue | undefined, p: DatabaseProperty): boolean { if (!v) return true; if (p.type === "multi_select") return !v.optionIds?.length; if (p.type === "person") return !v.userIds?.length; if (p.type === "date") return !v.start; if (p.type === "select" || p.type === "status") return !v.optionId; return !v.optionId && !v.optionIds?.length && !v.userIds?.length && !v.start; }