web(kanban): add drag-and-drop, card detail modal, and column rename
- Cards are draggable between columns via @dnd-kit with optimistic updates - Clicking a card opens the EntryDetailModal (parity with table view) - Double-click column headers to rename enum values inline - New API endpoint for renaming enum values with cascading entry updates
This commit is contained in:
parent
840f5c4f08
commit
4995eb52fe
@ -0,0 +1,114 @@
|
||||
import { duckdbExec, duckdbQuery, duckdbPath } from "@/lib/workspace";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
|
||||
function sqlEscape(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspace/objects/[name]/fields/[fieldId]/enum-rename
|
||||
* Rename an enum value across the field definition and all entries.
|
||||
* Body: { oldValue: string, newValue: string }
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{
|
||||
params,
|
||||
}: { params: Promise<{ name: string; fieldId: string }> },
|
||||
) {
|
||||
const { name, fieldId } = await params;
|
||||
|
||||
if (!duckdbPath()) {
|
||||
return Response.json(
|
||||
{ error: "DuckDB not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||
return Response.json(
|
||||
{ error: "Invalid object name" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const oldValue: string = body.oldValue;
|
||||
const newValue: string = body.newValue;
|
||||
|
||||
if (!oldValue || !newValue || typeof oldValue !== "string" || typeof newValue !== "string") {
|
||||
return Response.json(
|
||||
{ error: "oldValue and newValue are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (oldValue.trim() === newValue.trim()) {
|
||||
return Response.json({ ok: true, changed: 0 });
|
||||
}
|
||||
|
||||
// Validate object exists
|
||||
const objects = duckdbQuery<{ id: string }>(
|
||||
`SELECT id FROM objects WHERE name = '${sqlEscape(name)}' LIMIT 1`,
|
||||
);
|
||||
if (objects.length === 0) {
|
||||
return Response.json(
|
||||
{ error: `Object '${name}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const objectId = objects[0].id;
|
||||
|
||||
// Validate field exists and is an enum
|
||||
const fields = duckdbQuery<{ id: string; enum_values: string | null; enum_colors: string | null }>(
|
||||
`SELECT id, enum_values, enum_colors FROM fields WHERE id = '${sqlEscape(fieldId)}' AND object_id = '${sqlEscape(objectId)}'`,
|
||||
);
|
||||
if (fields.length === 0) {
|
||||
return Response.json(
|
||||
{ error: "Field not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const field = fields[0];
|
||||
let enumValues: string[];
|
||||
try {
|
||||
enumValues = field.enum_values ? JSON.parse(field.enum_values) : [];
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ error: "Invalid enum_values in field" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const idx = enumValues.indexOf(oldValue.trim());
|
||||
if (idx === -1) {
|
||||
return Response.json(
|
||||
{ error: `Enum value '${oldValue}' not found` },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (enumValues.includes(newValue.trim())) {
|
||||
return Response.json(
|
||||
{ error: `Enum value '${newValue}' already exists` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update enum_values array
|
||||
enumValues[idx] = newValue.trim();
|
||||
const newEnumJson = JSON.stringify(enumValues);
|
||||
|
||||
duckdbExec(
|
||||
`UPDATE fields SET enum_values = '${sqlEscape(newEnumJson)}' WHERE id = '${sqlEscape(fieldId)}'`,
|
||||
);
|
||||
|
||||
// Update all entry_fields with the old value to the new value
|
||||
const updatedEntries = duckdbExec(
|
||||
`UPDATE entry_fields SET value = '${sqlEscape(newValue.trim())}' WHERE field_id = '${sqlEscape(fieldId)}' AND value = '${sqlEscape(oldValue.trim())}'`,
|
||||
);
|
||||
|
||||
return Response.json({ ok: true, updated: updatedEntries });
|
||||
}
|
||||
@ -1,6 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCorners,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
|
||||
type Field = {
|
||||
id: string;
|
||||
@ -25,6 +37,8 @@ type ObjectKanbanProps = {
|
||||
statuses: Status[];
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
onEntryClick?: (entryId: string) => void;
|
||||
onRefresh?: () => void;
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
@ -44,9 +58,71 @@ function parseRelationValue(value: string | null | undefined): string[] {
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
// --- Card component ---
|
||||
function getEntryTitle(entry: Record<string, unknown>, fields: Field[]): string {
|
||||
const titleField = fields.find(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes("name") ||
|
||||
f.name.toLowerCase().includes("title"),
|
||||
);
|
||||
return titleField
|
||||
? String(entry[titleField.name] ?? "Untitled")
|
||||
: String(entry[fields[0]?.name] ?? "Untitled");
|
||||
}
|
||||
|
||||
function KanbanCard({
|
||||
// --- Draggable Card ---
|
||||
|
||||
function DraggableCard({
|
||||
entry,
|
||||
fields,
|
||||
members,
|
||||
relationLabels,
|
||||
onEntryClick,
|
||||
}: {
|
||||
entry: Record<string, unknown>;
|
||||
fields: Field[];
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
onEntryClick?: (entryId: string) => void;
|
||||
}) {
|
||||
const entryId = String(entry.entry_id ?? "");
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: entryId,
|
||||
data: { entry },
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={(e) => {
|
||||
// Only open if not dragging
|
||||
if (!isDragging && onEntryClick) {
|
||||
e.stopPropagation();
|
||||
onEntryClick(entryId);
|
||||
}
|
||||
}}
|
||||
className="rounded-lg p-3 mb-2 transition-all duration-100 cursor-grab active:cursor-grabbing select-none"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: `1px solid ${isDragging ? "var(--color-accent)" : "var(--color-border)"}`,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
transform: isDragging ? "scale(1.02)" : undefined,
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
entry={entry}
|
||||
fields={fields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Card content (shared between draggable + overlay) ---
|
||||
|
||||
function CardContent({
|
||||
entry,
|
||||
fields,
|
||||
members,
|
||||
@ -57,7 +133,8 @@ function KanbanCard({
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
}) {
|
||||
// Show first 4 non-status fields
|
||||
const title = getEntryTitle(entry, fields);
|
||||
|
||||
const displayFields = fields
|
||||
.filter(
|
||||
(f) =>
|
||||
@ -68,41 +145,20 @@ function KanbanCard({
|
||||
)
|
||||
.slice(0, 4);
|
||||
|
||||
// Find a "name" or "title" field for the card header
|
||||
const titleField = fields.find(
|
||||
(f) =>
|
||||
f.name.toLowerCase().includes("name") ||
|
||||
f.name.toLowerCase().includes("title"),
|
||||
);
|
||||
const title = titleField
|
||||
? String(entry[titleField.name] ?? "Untitled")
|
||||
: String(entry[fields[0]?.name] ?? "Untitled");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg p-3 mb-2 transition-all duration-100 cursor-pointer"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-text-muted)";
|
||||
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.borderColor =
|
||||
"var(--color-border)";
|
||||
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className="text-sm font-medium mb-1.5 truncate"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{displayFields
|
||||
.filter((f) => f !== titleField)
|
||||
@ -111,7 +167,6 @@ function KanbanCard({
|
||||
const val = entry[field.name];
|
||||
if (!val) {return null;}
|
||||
|
||||
// Resolve display value based on field type
|
||||
let displayVal = String(val);
|
||||
if (field.type === "user") {
|
||||
const member = members?.find((m) => m.id === displayVal);
|
||||
@ -168,7 +223,7 @@ function KanbanCard({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -197,6 +252,171 @@ function EnumBadgeMini({
|
||||
);
|
||||
}
|
||||
|
||||
// --- Droppable Column ---
|
||||
|
||||
function DroppableColumn({
|
||||
columnName,
|
||||
color,
|
||||
items,
|
||||
cardFields,
|
||||
members,
|
||||
relationLabels,
|
||||
onEntryClick,
|
||||
isOver,
|
||||
groupFieldId,
|
||||
objectName,
|
||||
onRefresh,
|
||||
}: {
|
||||
columnName: string;
|
||||
color: string;
|
||||
items: Record<string, unknown>[];
|
||||
cardFields: Field[];
|
||||
members?: Array<{ id: string; name: string }>;
|
||||
relationLabels?: Record<string, Record<string, string>>;
|
||||
onEntryClick?: (entryId: string) => void;
|
||||
isOver: boolean;
|
||||
groupFieldId?: string;
|
||||
objectName: string;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
const { setNodeRef } = useDroppable({ id: `column:${columnName}` });
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameValue, setNameValue] = useState(columnName);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingName && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [editingName]);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
const trimmed = nameValue.trim();
|
||||
if (!trimmed || trimmed === columnName || !groupFieldId) {
|
||||
setEditingName(false);
|
||||
setNameValue(columnName);
|
||||
return;
|
||||
}
|
||||
|
||||
setRenaming(true);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(objectName)}/fields/${encodeURIComponent(groupFieldId)}/enum-rename`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ oldValue: columnName, newValue: trimmed }),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
onRefresh?.();
|
||||
} else {
|
||||
setNameValue(columnName);
|
||||
}
|
||||
} catch {
|
||||
setNameValue(columnName);
|
||||
} finally {
|
||||
setRenaming(false);
|
||||
setEditingName(false);
|
||||
}
|
||||
}, [nameValue, columnName, groupFieldId, objectName, onRefresh]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className="flex-shrink-0 flex flex-col rounded-xl transition-colors duration-150"
|
||||
style={{
|
||||
width: "280px",
|
||||
background: isOver ? "var(--color-surface)" : "var(--color-bg)",
|
||||
border: `1px solid ${isOver ? "var(--color-accent)" : "var(--color-border)"}`,
|
||||
}}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2.5 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: color }}
|
||||
/>
|
||||
{editingName ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={nameValue}
|
||||
onChange={(e) => setNameValue(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {handleRename();}
|
||||
if (e.key === "Escape") {
|
||||
setNameValue(columnName);
|
||||
setEditingName(false);
|
||||
}
|
||||
}}
|
||||
disabled={renaming}
|
||||
className="text-sm font-medium flex-1 bg-transparent outline-none rounded px-1 -mx-1"
|
||||
style={{
|
||||
color: "var(--color-text)",
|
||||
border: "1px solid var(--color-accent)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-medium flex-1 cursor-text rounded px-1 -mx-1 hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
onDoubleClick={() => {
|
||||
if (groupFieldId) {
|
||||
setNameValue(columnName);
|
||||
setEditingName(true);
|
||||
}
|
||||
}}
|
||||
title={groupFieldId ? "Double-click to rename" : undefined}
|
||||
>
|
||||
{columnName}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="flex-1 overflow-y-auto p-2" style={{ minHeight: "80px" }}>
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-8 rounded-lg border border-dashed text-xs transition-colors"
|
||||
style={{
|
||||
borderColor: isOver ? "var(--color-accent)" : "var(--color-border)",
|
||||
color: isOver ? "var(--color-accent)" : "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{isOver ? "Drop here" : "No entries"}
|
||||
</div>
|
||||
) : (
|
||||
items.map((entry, idx) => (
|
||||
<DraggableCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
onEntryClick={onEntryClick}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Kanban Board ---
|
||||
|
||||
export function ObjectKanban({
|
||||
@ -206,8 +426,26 @@ export function ObjectKanban({
|
||||
statuses,
|
||||
members,
|
||||
relationLabels,
|
||||
onEntryClick,
|
||||
onRefresh,
|
||||
}: ObjectKanbanProps) {
|
||||
// Find the grouping field: prefer a "Status" enum field, fallback to first enum
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [overColumnId, setOverColumnId] = useState<string | null>(null);
|
||||
// Optimistic local entries for instant drag feedback
|
||||
const [localEntries, setLocalEntries] = useState(entries);
|
||||
|
||||
// Sync when parent entries change
|
||||
useEffect(() => {
|
||||
setLocalEntries(entries);
|
||||
}, [entries]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
);
|
||||
|
||||
// Find the grouping field
|
||||
const groupField = useMemo(() => {
|
||||
const statusField = fields.find(
|
||||
(f) =>
|
||||
@ -218,7 +456,7 @@ export function ObjectKanban({
|
||||
return fields.find((f) => f.type === "enum") ?? null;
|
||||
}, [fields]);
|
||||
|
||||
// Determine columns: from statuses table, or from enum_values, or from unique values
|
||||
// Determine columns
|
||||
const columns = useMemo(() => {
|
||||
if (statuses.length > 0) {
|
||||
return statuses.map((s) => ({
|
||||
@ -232,24 +470,21 @@ export function ObjectKanban({
|
||||
color: groupField.enum_colors?.[i] ?? "#94a3b8",
|
||||
}));
|
||||
}
|
||||
// Fallback: derive from data
|
||||
const unique = new Set<string>();
|
||||
for (const e of entries) {
|
||||
for (const e of localEntries) {
|
||||
const val = groupField ? e[groupField.name] : undefined;
|
||||
if (val) {unique.add(String(val));}
|
||||
}
|
||||
return Array.from(unique).map((v) => ({ name: v, color: "#94a3b8" }));
|
||||
}, [statuses, groupField, entries]);
|
||||
}, [statuses, groupField, localEntries]);
|
||||
|
||||
// Group entries by column
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, Record<string, unknown>[]> = {};
|
||||
for (const col of columns) {groups[col.name] = [];}
|
||||
|
||||
// Ungrouped bucket
|
||||
groups["_ungrouped"] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const entry of localEntries) {
|
||||
const val = groupField ? String(entry[groupField.name] ?? "") : "";
|
||||
if (groups[val]) {
|
||||
groups[val].push(entry);
|
||||
@ -258,11 +493,96 @@ export function ObjectKanban({
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [columns, entries, groupField]);
|
||||
}, [columns, localEntries, groupField]);
|
||||
|
||||
// Non-grouping fields for cards
|
||||
const cardFields = fields.filter((f) => f !== groupField);
|
||||
|
||||
// Active drag entry for overlay
|
||||
const activeEntry = useMemo(() => {
|
||||
if (!activeId) {return null;}
|
||||
return localEntries.find((e) => String(e.entry_id) === activeId) ?? null;
|
||||
}, [activeId, localEntries]);
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
// Track which column is being hovered
|
||||
const handleDragOver = useCallback((event: { over: { id: string | number } | null }) => {
|
||||
const overId = event.over?.id ? String(event.over.id) : null;
|
||||
if (overId?.startsWith("column:")) {
|
||||
setOverColumnId(overId.replace("column:", ""));
|
||||
} else {
|
||||
setOverColumnId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle drag end - move card to new column
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
setOverColumnId(null);
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over || !groupField) {return;}
|
||||
|
||||
const overId = String(over.id);
|
||||
if (!overId.startsWith("column:")) {return;}
|
||||
|
||||
const targetColumn = overId.replace("column:", "");
|
||||
const entryId = String(active.id);
|
||||
const entry = localEntries.find((e) => String(e.entry_id) === entryId);
|
||||
if (!entry) {return;}
|
||||
|
||||
const currentValue = String(entry[groupField.name] ?? "");
|
||||
if (currentValue === targetColumn) {return;}
|
||||
|
||||
// Optimistic update
|
||||
setLocalEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
String(e.entry_id) === entryId
|
||||
? { ...e, [groupField.name]: targetColumn }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
|
||||
// Persist via API
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/workspace/objects/${encodeURIComponent(objectName)}/entries/${encodeURIComponent(entryId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: { [groupField.name]: targetColumn } }),
|
||||
},
|
||||
);
|
||||
if (res.ok) {
|
||||
onRefresh?.();
|
||||
} else {
|
||||
// Revert on failure
|
||||
setLocalEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
String(e.entry_id) === entryId
|
||||
? { ...e, [groupField.name]: currentValue }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Revert on error
|
||||
setLocalEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
String(e.entry_id) === entryId
|
||||
? { ...e, [groupField.name]: currentValue }
|
||||
: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[groupField, localEntries, objectName, onRefresh],
|
||||
);
|
||||
|
||||
if (!groupField) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||||
@ -277,30 +597,53 @@ export function ObjectKanban({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 px-1" style={{ minHeight: "400px" }}>
|
||||
{columns.map((col) => {
|
||||
const items = grouped[col.name] ?? [];
|
||||
return (
|
||||
<div
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto pb-4 px-1"
|
||||
style={{ minHeight: "400px" }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<DroppableColumn
|
||||
key={col.name}
|
||||
columnName={col.name}
|
||||
color={col.color}
|
||||
items={grouped[col.name] ?? []}
|
||||
cardFields={cardFields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
onEntryClick={onEntryClick}
|
||||
isOver={overColumnId === col.name}
|
||||
groupFieldId={groupField.id}
|
||||
objectName={objectName}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ungrouped entries */}
|
||||
{grouped["_ungrouped"]?.length > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col rounded-xl"
|
||||
style={{
|
||||
width: "280px",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
border: "1px dashed var(--color-border)",
|
||||
}}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2.5 border-b"
|
||||
style={{ borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
|
||||
style={{ background: col.color }}
|
||||
/>
|
||||
<span
|
||||
className="text-sm font-medium flex-1"
|
||||
style={{ color: "var(--color-text)" }}
|
||||
className="text-sm font-medium"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{col.name}
|
||||
Ungrouped
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
@ -309,75 +652,46 @@ export function ObjectKanban({
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{items.length}
|
||||
{grouped["_ungrouped"].length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
className="flex items-center justify-center py-8 rounded-lg border border-dashed text-xs"
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
No entries
|
||||
</div>
|
||||
) : (
|
||||
items.map((entry, idx) => (
|
||||
<KanbanCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{grouped["_ungrouped"].map((entry, idx) => (
|
||||
<DraggableCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
onEntryClick={onEntryClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ungrouped entries */}
|
||||
{grouped["_ungrouped"]?.length > 0 && (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col rounded-xl"
|
||||
style={{
|
||||
width: "280px",
|
||||
background: "var(--color-bg)",
|
||||
border: "1px dashed var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b" style={{ borderColor: "var(--color-border)" }}>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||||
Ungrouped
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded-full"
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{grouped["_ungrouped"].length}
|
||||
</span>
|
||||
{/* Drag overlay - floating card that follows cursor */}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeEntry ? (
|
||||
<div
|
||||
className="rounded-lg p-3 shadow-xl"
|
||||
style={{
|
||||
width: "260px",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-accent)",
|
||||
transform: "rotate(2deg)",
|
||||
}}
|
||||
>
|
||||
<CardContent
|
||||
entry={activeEntry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{grouped["_ungrouped"].map((entry, idx) => (
|
||||
<KanbanCard
|
||||
key={String(entry.entry_id ?? idx)}
|
||||
entry={entry}
|
||||
fields={cardFields}
|
||||
members={members}
|
||||
relationLabels={relationLabels}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1298,6 +1298,8 @@ function ObjectView({
|
||||
statuses={data.statuses}
|
||||
members={members}
|
||||
relationLabels={data.relationLabels}
|
||||
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
|
||||
onRefresh={onRefreshObject}
|
||||
/>
|
||||
) : (
|
||||
<ObjectTable
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user