1"use client";
2
3
19import { useMemo, useState } from "react";
20import {
21 DndContext,
22 DragEndEvent,
23 DragOverlay,
24 DragStartEvent,
25 PointerSensor,
26 useSensor,
27 useSensors,
28 useDraggable,
29 useDroppable,
30 type CollisionDetection
31} from "@dnd-kit/core";
32import { ChevronDown, Filter, Plus } from "lucide-react";
33import { cn } from "@/lib/utils";
34import {
35 DatabaseProperty,
36 DatabaseView,
37 RecordDto,
38 RecordGroup,
39 RecordValue,
40 UseDatabaseRecordsResult,
41 useCreateRecord,
42 useUpdateRecord,
43 valueToText
44} from "@/hooks/use-database";
45import { cellToText } from "@/components/database/cell-renderer";
46
47export interface BoardProps {
48 databaseId: string;
49 view: DatabaseView;
50 properties: DatabaseProperty[];
51 records: UseDatabaseRecordsResult;
52 onOpenRecord: (record: RecordDto) => void;
53 readOnly?: boolean;
54}
55
56type ColumnKey = string;
57
58const NO_GROUP_KEY = "__no_group__";
59
60function columnKey(g: RecordGroup): ColumnKey {
61 return JSON.stringify(g.key.value);
62}
63
64
65function groupValueForColumn(key: ColumnKey, groupProperty: DatabaseProperty): RecordValue {
66 const v = JSON.parse(key) as RecordValue | string | number;
67
68 if (groupProperty.type === "date" && typeof v === "string") {
69 return { start: v, end: null };
70 }
71 const value = (v ?? {}) as RecordValue;
72 if (groupProperty.type === "multi_select") {
73 return { optionIds: [value.optionId].filter(Boolean) as string[] };
74 }
75 return value;
76}
77
78
79
80
81
82function CardContent({ record, titleProp, props, compact }: { record: RecordDto; titleProp?: DatabaseProperty; props: DatabaseProperty[]; compact?: boolean }) {
83 const chips = (viewConfigCardProperties(props, titleProp)).slice(0, 2);
84 return (
85 <div className={cn("rounded-md border bg-card p-2 text-left shadow-sm", compact && "cursor-grabbing")}>
86 <p className="truncate text-sm font-medium">
87 {record.icon ? `${record.icon} ` : ""}
88 {titleProp ? valueToText(record.properties[titleProp.id], titleProp) || "Untitled" : record.id.slice(-6)}
89 </p>
90 {!compact && (
91 <div className="mt-1.5 flex flex-wrap gap-1">
92 {chips.map((p) => {
93 const text = cellToText(record.properties[p.id], p);
94 if (!text) return null;
95 return (
96 <span key={p.id} className="inline-flex max-w-full items-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
97 <span className="opacity-70">{p.name}:</span>
98 <span className="truncate">{text}</span>
99 </span>
100 );
101 })}
102 </div>
103 )}
104 </div>
105 );
106}
107
108function viewConfigCardProperties(props: DatabaseProperty[], titleProp?: DatabaseProperty): DatabaseProperty[] {
109
110
111 return props.filter((p) => p !== titleProp && p.type !== "relation" && p.type !== "files").slice(0, 2);
112}
113
114function DraggableCard({ record, titleProp, props, onOpen, disabled }: { record: RecordDto; titleProp?: DatabaseProperty; props: DatabaseProperty[]; onOpen: (r: RecordDto) => void; disabled?: boolean }) {
115 const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
116 id: record.id,
117 disabled
118 });
119 return (
120 <div
121 ref={setNodeRef}
122 style={transform ? { transform: `translate(${transform.x}px, ${transform.y}px)` } : undefined}
123 className={cn(isDragging && "opacity-40")}
124 >
125 <div
126 className={cn(!disabled && "cursor-grab active:cursor-grabbing")}
127 onClick={() => onOpen(record)}
128 {...listeners}
129 {...attributes}
130 >
131 <CardContent record={record} titleProp={titleProp} props={props} />
132 </div>
133 </div>
134 );
135}
136
137
138
139
140
141function BoardColumn({
142 group,
143 groupProperty,
144 titleProp,
145 properties,
146 onCreate,
147 onOpen,
148 draggableDisabled,
149 quickFilter,
150 onQuickFilter,
151 droppableId
152}: {
153 group: RecordGroup | null;
154 groupProperty: DatabaseProperty;
155 titleProp?: DatabaseProperty;
156 properties: DatabaseProperty[];
157 onCreate: (value: RecordValue | null) => void;
158 onOpen: (r: RecordDto) => void;
159 draggableDisabled: boolean;
160 quickFilter: boolean;
161 onQuickFilter: (v: boolean) => void;
162
163 droppableId?: string;
164}) {
165 const [collapsed, setCollapsed] = useState(false);
166 const { setNodeRef, isOver } = useDroppable({ id: droppableId ?? columnKey(group) });
167 const records = group?.records ?? [];
168 const label = group?.key.label ?? "No group";
169
170 return (
171 <div className={cn("flex w-72 shrink-0 flex-col rounded-lg bg-muted/40", isOver && "ring-2 ring-primary/40")}>
172 <div className="flex items-center gap-1 px-2 py-1.5">
173 <button
174 type="button"
175 className="text-muted-foreground hover:text-foreground"
176 onClick={() => setCollapsed((c) => !c)}
177 aria-label={collapsed ? "Expand column" : "Collapse column"}
178 >
179 <ChevronDown className={cn("size-3.5 transition-transform", collapsed && "-rotate-90")} />
180 </button>
181 <span className="truncate text-xs font-medium">{label}</span>
182 <span className="rounded-full bg-background px-1.5 text-[11px] text-muted-foreground">{records.length}</span>
183 <div className="ml-auto flex items-center gap-0.5">
184 <button
185 type="button"
186 className={cn("rounded p-0.5 text-muted-foreground hover:bg-muted", quickFilter && "bg-primary/10 text-primary")}
187 onClick={() => onQuickFilter(!quickFilter)}
188 title="Quick filter (contains)"
189 aria-label="Toggle quick filter"
190 >
191 <Filter className="size-3.5" />
192 </button>
193 <button type="button" className="rounded p-0.5 text-muted-foreground hover:bg-muted" onClick={() => onCreate(null)} title="Add record to this group" aria-label="Add record">
194 <Plus className="size-3.5" />
195 </button>
196 </div>
197 </div>
198 {collapsed ? null : (
199 <div ref={setNodeRef} className="flex min-h-16 flex-1 flex-col gap-1.5 overflow-y-auto p-1.5 pt-0">
200 {records.map((r) => (
201 <DraggableCard key={r.id} record={r} titleProp={titleProp} props={properties} onOpen={onOpen} disabled={draggableDisabled} />
202 ))}
203 {records.length === 0 && (
204 <p className="px-1 py-3 text-center text-xs text-muted-foreground">
205 {quickFilter ? "No matching cards" : "Drop cards here"}
206 </p>
207 )}
208 </div>
209 )}
210 </div>
211 );
212}
213
214
215
216const kanbanCollision: CollisionDetection = (args) => {
217
218 const pointer = args.pointerCoordinates;
219 for (const { droppable } of args.droppableContainers) {
220 const box = droppable.rect.current.translated;
221 if (box && pointer.x >= box.left && pointer.x <= box.right && pointer.y >= box.top && pointer.y <= box.bottom) {
222 return [droppable.id as unknown as { id: string }];
223 }
224 }
225 return [];
226};
227
228export function BoardView({ databaseId, view, properties, records, onOpenRecord, readOnly }: BoardProps) {
229 const { data, isLoading } = records;
230 const updateRecord = useUpdateRecord(databaseId);
231 const createRecord = useCreateRecord(databaseId);
232
233 const groupSpec = view.group?.[0];
234 const groupProperty = groupSpec ? properties.find((p) => p.id === groupSpec.propertyId) : undefined;
235 const titleProp = properties.find((p) => p.isTitle);
236
237 const [activeId, setActiveId] = useState<string | null>(null);
238 const [quickFilterOn, setQuickFilterOn] = useState(false);
239 const [quickText, setQuickText] = useState("");
240 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
241
242
243 const groups = useMemo(() => {
244 if (!groupProperty) return { list: [] as RecordGroup[], noGroup: [] as RecordDto[] };
245 const byKey = new Map<ColumnKey, RecordGroup>();
246 for (const g of data.groups ?? []) {
247 if (g.key.propertyId !== groupProperty.id) continue;
248 byKey.set(columnKey(g), g);
249 }
250
251 const noGroup: RecordDto[] = [];
252 for (const r of data.records) {
253 if (isGroupValueEmpty(r.properties[groupProperty.id], groupProperty)) noGroup.push(r);
254 }
255 return { list: Array.from(byKey.values()), noGroup };
256 }, [data, groupProperty]);
257
258
259 const quickFilterActive = quickFilterOn && quickText.trim().length > 0;
260 const effectiveGroups = useMemo(() => {
261 if (!quickFilterActive) return groups;
262 const t = quickText.trim().toLowerCase();
263 const match = (r: RecordDto) =>
264 (titleProp ? valueToText(r.properties[titleProp.id], titleProp) : "").toLowerCase().includes(t);
265 const list = groups.list.map((g) => ({
266 ...g,
267 records: g.records.filter(match),
268 count: g.records.filter(match).length
269 }));
270 return { ...groups, list, noGroup: groups.noGroup.filter(match) };
271 }, [groups, quickFilterActive, quickText, titleProp]);
272
273
274 const onDragStart = (e: DragStartEvent) => setActiveId(String(e.active.id));
275 const onDragEnd = (e: DragEndEvent) => {
276 setActiveId(null);
277 if (readOnly || !e.over || !groupProperty) return;
278 const overKey = String(e.over.id) as ColumnKey;
279 const recordId = String(e.active.id);
280 const allRecords = effectiveGroups.list.flatMap((g) => g.records).concat(effectiveGroups.noGroup);
281 const record = allRecords.find((r) => r.id === recordId);
282 if (!record) return;
283
284
285 const inList = effectiveGroups.list.find((g) => g.records.some((r) => r.id === recordId));
286 const currentKey = inList ? columnKey(inList) : NO_GROUP_KEY;
287 if (currentKey === overKey) return;
288
289
290 const target: RecordValue =
291 overKey === NO_GROUP_KEY
292 ? { optionId: null as unknown as string, optionIds: [], userIds: [], start: null }
293 : groupValueForColumn(overKey, groupProperty);
294 if (!isGroupValueEmpty(target, groupProperty)) {
295 target.optionId = target.optionId ?? null;
296 }
297
298
299
300 updateRecord.mutate({
301 recordId,
302 version: record.version,
303 properties: { [groupProperty.id]: target }
304 });
305 };
306
307 const createInGroup = (groupKey: ColumnKey) => {
308 if (!groupProperty) return;
309 if (groupKey === NO_GROUP_KEY) {
310 createRecord.mutate({ properties: {} });
311 return;
312 }
313 createRecord.mutate({ properties: { [groupProperty.id]: groupValueForColumn(groupKey, groupProperty) } });
314 };
315
316 const activeRecord = activeId
317 ? effectiveGroups.list.flatMap((g) => g.records).concat(effectiveGroups.noGroup).find((r) => r.id === activeId)
318 : null;
319
320 if (!groupProperty) {
321 return (
322 <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
323 This board view has no group. Open the view's Group settings and pick a select, status, multi-select, person or date property.
324 </div>
325 );
326 }
327
328 return (
329 <DndContext
330 sensors={sensors}
331 collisionDetection={kanbanCollision}
332 onDragStart={onDragStart}
333 onDragEnd={onDragEnd}
334 >
335 <div className="flex h-full items-start gap-3 overflow-x-auto p-3">
336 {effectiveGroups.list.map((g) => (
337 <BoardColumn
338 key={columnKey(g)}
339 group={g}
340 groupProperty={groupProperty}
341 titleProp={titleProp}
342 properties={properties}
343 onCreate={() => createInGroup(columnKey(g))}
344 onOpen={onOpenRecord}
345 draggableDisabled={!!readOnly}
346 quickFilter={quickFilterOn}
347 onQuickFilter={setQuickFilterOn}
348 />
349 ))}
350 {}
351 <div className="flex flex-col gap-1.5">
352 {quickFilterOn && (
353 <input
354 className="w-72 rounded-md border border-input bg-background px-2 py-1 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring"
355 placeholder="Filter cards (contains)…"
356 value={quickText}
357 onChange={(e) => setQuickText(e.target.value)}
358 aria-label="Quick filter cards"
359 />
360 )}
361 <BoardColumn
362 group={
363 effectiveGroups.noGroup.length
364 ? {
365 key: { propertyId: groupProperty.id, value: null as unknown as RecordValue, label: "No group" },
366 count: effectiveGroups.noGroup.length,
367 records: effectiveGroups.noGroup
368 }
369 : null
370 }
371 groupProperty={groupProperty}
372 titleProp={titleProp}
373 properties={properties}
374 onCreate={() => createInGroup(NO_GROUP_KEY)}
375 onOpen={onOpenRecord}
376 draggableDisabled={!!readOnly}
377 quickFilter={false}
378 onQuickFilter={() => undefined}
379 droppableId={NO_GROUP_KEY}
380 />
381 </div>
382 </div>
383 <DragOverlay>
384 {activeRecord ? (
385 <CardContent record={activeRecord} titleProp={titleProp} props={properties} compact />
386 ) : null}
387 </DragOverlay>
388 </DndContext>
389 );
390}
391
392function isGroupValueEmpty(v: RecordValue | undefined, p: DatabaseProperty): boolean {
393 if (!v) return true;
394 if (p.type === "multi_select") return !v.optionIds?.length;
395 if (p.type === "person") return !v.userIds?.length;
396 if (p.type === "date") return !v.start;
397 if (p.type === "select" || p.type === "status") return !v.optionId;
398 return !v.optionId && !v.optionIds?.length && !v.userIds?.length && !v.start;
399}
400