diff --git a/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/enum-rename/route.ts b/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/enum-rename/route.ts new file mode 100644 index 00000000000..68402340a24 --- /dev/null +++ b/apps/web/app/api/workspace/objects/[name]/fields/[fieldId]/enum-rename/route.ts @@ -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 }); +} diff --git a/apps/web/app/components/workspace/object-kanban.tsx b/apps/web/app/components/workspace/object-kanban.tsx index 7e198ee2582..9dd2108bb5d 100644 --- a/apps/web/app/components/workspace/object-kanban.tsx +++ b/apps/web/app/components/workspace/object-kanban.tsx @@ -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>; + 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, 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; + fields: Field[]; + members?: Array<{ id: string; name: string }>; + relationLabels?: Record>; + onEntryClick?: (entryId: string) => void; +}) { + const entryId = String(entry.entry_id ?? ""); + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: entryId, + data: { entry }, + }); + + return ( +
{ + // 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, + }} + > + +
+ ); +} + +// --- 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>; }) { - // 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 ( -
{ - (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)"; - }} - > + <>
{title}
-
{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({ ); })}
-
+ ); } @@ -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[]; + cardFields: Field[]; + members?: Array<{ id: string; name: string }>; + relationLabels?: Record>; + 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(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 ( +
+ {/* Column header */} +
+ + {editingName ? ( + 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)", + }} + /> + ) : ( + { + if (groupFieldId) { + setNameValue(columnName); + setEditingName(true); + } + }} + title={groupFieldId ? "Double-click to rename" : undefined} + > + {columnName} + + )} + + {items.length} + +
+ + {/* Cards */} +
+ {items.length === 0 ? ( +
+ {isOver ? "Drop here" : "No entries"} +
+ ) : ( + items.map((entry, idx) => ( + + )) + )} +
+
+ ); +} + // --- 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(null); + const [overColumnId, setOverColumnId] = useState(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(); - 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[]> = {}; 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 (
@@ -277,30 +597,53 @@ export function ObjectKanban({ } return ( -
- {columns.map((col) => { - const items = grouped[col.name] ?? []; - return ( -
+
+ {columns.map((col) => ( + + ))} + + {/* Ungrouped entries */} + {grouped["_ungrouped"]?.length > 0 && ( +
- {/* Column header */} -
+
- - {col.name} + Ungrouped - {items.length} + {grouped["_ungrouped"].length}
- - {/* Cards */}
- {items.length === 0 ? ( -
- No entries -
- ) : ( - items.map((entry, idx) => ( - - )) - )} + {grouped["_ungrouped"].map((entry, idx) => ( + + ))}
- ); - })} + )} +
- {/* Ungrouped entries */} - {grouped["_ungrouped"]?.length > 0 && ( -
-
- - Ungrouped - - - {grouped["_ungrouped"].length} - + {/* Drag overlay - floating card that follows cursor */} + + {activeEntry ? ( +
+
-
- {grouped["_ungrouped"].map((entry, idx) => ( - - ))} -
-
- )} -
+ ) : null} + + ); } diff --git a/apps/web/app/workspace/page.tsx b/apps/web/app/workspace/page.tsx index 0ca51d5ae3c..20eb8336f06 100644 --- a/apps/web/app/workspace/page.tsx +++ b/apps/web/app/workspace/page.tsx @@ -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} /> ) : (