320 lines
8.8 KiB
TypeScript
320 lines
8.8 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import {
|
||
|
|
DndContext,
|
||
|
|
DragOverlay,
|
||
|
|
PointerSensor,
|
||
|
|
closestCorners,
|
||
|
|
useSensor,
|
||
|
|
useSensors,
|
||
|
|
type DragEndEvent,
|
||
|
|
type DragOverEvent,
|
||
|
|
type DragStartEvent,
|
||
|
|
} from "@dnd-kit/core";
|
||
|
|
import { arrayMove } from "@dnd-kit/sortable";
|
||
|
|
|
||
|
|
import type { ViewConfig, ViewObject } from "@/lib/hooks/use-view-data";
|
||
|
|
import { useViewData } from "@/lib/hooks/use-view-data";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
import { BoardCardPreview } from "./board-card";
|
||
|
|
import { BoardColumn } from "./board-column";
|
||
|
|
|
||
|
|
const STATUS_ORDER = ["open", "in_progress", "done", "closed"] as const;
|
||
|
|
|
||
|
|
const COLUMN_THEME: Record<
|
||
|
|
string,
|
||
|
|
{ dot: string; borderTop: string; label: string }
|
||
|
|
> = {
|
||
|
|
open: {
|
||
|
|
dot: "bg-muted-foreground/55",
|
||
|
|
borderTop: "border-t-[3px] border-t-muted-foreground/45",
|
||
|
|
label: "Open",
|
||
|
|
},
|
||
|
|
in_progress: {
|
||
|
|
dot: "bg-blue-500",
|
||
|
|
borderTop: "border-t-[3px] border-t-blue-500",
|
||
|
|
label: "In progress",
|
||
|
|
},
|
||
|
|
done: {
|
||
|
|
dot: "bg-green-500",
|
||
|
|
borderTop: "border-t-[3px] border-t-green-500",
|
||
|
|
label: "Done",
|
||
|
|
},
|
||
|
|
closed: {
|
||
|
|
dot: "bg-slate-500",
|
||
|
|
borderTop: "border-t-[3px] border-t-slate-500",
|
||
|
|
label: "Closed",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
function getColumnTheme(columnId: string) {
|
||
|
|
return (
|
||
|
|
COLUMN_THEME[columnId] ?? {
|
||
|
|
dot: "bg-muted-foreground/50",
|
||
|
|
borderTop: "border-t-[3px] border-t-muted-foreground/35",
|
||
|
|
label: columnId.replace(/_/g, " "),
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function getColumnKeys(
|
||
|
|
groupBy: string | null,
|
||
|
|
grouped: Record<string, ViewObject[]>,
|
||
|
|
): string[] {
|
||
|
|
if (groupBy === "status" || groupBy === null) {
|
||
|
|
return [...STATUS_ORDER];
|
||
|
|
}
|
||
|
|
return Object.keys(grouped).sort();
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildColumnsFromGrouped(
|
||
|
|
columnKeys: string[],
|
||
|
|
grouped: Record<string, ViewObject[]>,
|
||
|
|
): Record<string, ViewObject[]> {
|
||
|
|
const out: Record<string, ViewObject[]> = {};
|
||
|
|
for (const k of columnKeys) {
|
||
|
|
out[k] = grouped[k] ? [...grouped[k]] : [];
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function findContainer(
|
||
|
|
id: string,
|
||
|
|
cols: Record<string, ViewObject[]>,
|
||
|
|
): string | undefined {
|
||
|
|
if (id in cols) return id;
|
||
|
|
for (const key of Object.keys(cols)) {
|
||
|
|
if (cols[key].some((o) => o.id === id)) return key;
|
||
|
|
}
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
function patchObjectForColumn(
|
||
|
|
object: ViewObject,
|
||
|
|
columnId: string,
|
||
|
|
groupField: string,
|
||
|
|
): ViewObject {
|
||
|
|
const field = groupField === "status" ? "status" : groupField;
|
||
|
|
if (field === "status") {
|
||
|
|
return { ...object, status: columnId };
|
||
|
|
}
|
||
|
|
return { ...object, [field]: columnId } as ViewObject;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface BoardViewProps {
|
||
|
|
config: ViewConfig;
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function BoardView({ config, className }: BoardViewProps) {
|
||
|
|
const effectiveConfig = React.useMemo(
|
||
|
|
() => ({
|
||
|
|
...config,
|
||
|
|
groupBy: config.groupBy ?? "status",
|
||
|
|
}),
|
||
|
|
[config],
|
||
|
|
);
|
||
|
|
|
||
|
|
const { grouped, isLoading, total } = useViewData(effectiveConfig);
|
||
|
|
const groupField = effectiveConfig.groupBy ?? "status";
|
||
|
|
|
||
|
|
const columnKeys = React.useMemo(
|
||
|
|
() => getColumnKeys(effectiveConfig.groupBy, grouped),
|
||
|
|
[effectiveConfig.groupBy, grouped],
|
||
|
|
);
|
||
|
|
|
||
|
|
const initialColumns = React.useMemo(
|
||
|
|
() => buildColumnsFromGrouped(columnKeys, grouped),
|
||
|
|
[columnKeys, grouped],
|
||
|
|
);
|
||
|
|
|
||
|
|
const [columns, setColumns] =
|
||
|
|
React.useState<Record<string, ViewObject[]>>(initialColumns);
|
||
|
|
const [activeId, setActiveId] = React.useState<string | null>(null);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
setColumns(initialColumns);
|
||
|
|
}, [initialColumns]);
|
||
|
|
|
||
|
|
const sensors = useSensors(
|
||
|
|
useSensor(PointerSensor, {
|
||
|
|
activationConstraint: { distance: 8 },
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
|
||
|
|
const activeObject = React.useMemo(() => {
|
||
|
|
if (!activeId) return null;
|
||
|
|
for (const list of Object.values(columns)) {
|
||
|
|
const found = list.find((o) => o.id === activeId);
|
||
|
|
if (found) return found;
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}, [activeId, columns]);
|
||
|
|
|
||
|
|
const handleDragStart = (event: DragStartEvent) => {
|
||
|
|
setActiveId(String(event.active.id));
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragCancel = () => {
|
||
|
|
setActiveId(null);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragOver = (event: DragOverEvent) => {
|
||
|
|
const { active, over } = event;
|
||
|
|
if (!over) return;
|
||
|
|
|
||
|
|
const activeIdStr = String(active.id);
|
||
|
|
const overIdStr = String(over.id);
|
||
|
|
|
||
|
|
if (activeIdStr === overIdStr) return;
|
||
|
|
|
||
|
|
setColumns((prev) => {
|
||
|
|
const activeContainer = findContainer(activeIdStr, prev);
|
||
|
|
const overContainer = findContainer(overIdStr, prev);
|
||
|
|
|
||
|
|
if (!activeContainer || !overContainer) return prev;
|
||
|
|
if (activeContainer === overContainer) return prev;
|
||
|
|
|
||
|
|
const activeItems = [...prev[activeContainer]];
|
||
|
|
const overItems = [...prev[overContainer]];
|
||
|
|
const activeIndex = activeItems.findIndex((i) => i.id === activeIdStr);
|
||
|
|
if (activeIndex === -1) return prev;
|
||
|
|
|
||
|
|
let newIndex: number;
|
||
|
|
if (overIdStr in prev) {
|
||
|
|
newIndex = overItems.length;
|
||
|
|
} else {
|
||
|
|
const overItemIndex = overItems.findIndex((i) => i.id === overIdStr);
|
||
|
|
const isBelowOverItem =
|
||
|
|
over.rect &&
|
||
|
|
active.rect.current.translated &&
|
||
|
|
active.rect.current.translated.top > over.rect.top + over.rect.height;
|
||
|
|
|
||
|
|
const modifier = isBelowOverItem ? 1 : 0;
|
||
|
|
newIndex =
|
||
|
|
overItemIndex >= 0 ? overItemIndex + modifier : overItems.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
const [removed] = activeItems.splice(activeIndex, 1);
|
||
|
|
const patched = patchObjectForColumn(removed, overContainer, groupField);
|
||
|
|
const nextOver = [...overItems];
|
||
|
|
nextOver.splice(newIndex, 0, patched);
|
||
|
|
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
[activeContainer]: activeItems,
|
||
|
|
[overContainer]: nextOver,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
||
|
|
const { active, over } = event;
|
||
|
|
setActiveId(null);
|
||
|
|
|
||
|
|
if (!over) return;
|
||
|
|
|
||
|
|
const activeIdStr = String(active.id);
|
||
|
|
const overIdStr = String(over.id);
|
||
|
|
|
||
|
|
setColumns((prev) => {
|
||
|
|
const activeContainer = findContainer(activeIdStr, prev);
|
||
|
|
const overContainer = findContainer(overIdStr, prev);
|
||
|
|
|
||
|
|
if (!activeContainer || !overContainer) return prev;
|
||
|
|
|
||
|
|
if (activeContainer !== overContainer) {
|
||
|
|
const activeItems = [...prev[activeContainer]];
|
||
|
|
const overItems = [...prev[overContainer]];
|
||
|
|
const activeIndex = activeItems.findIndex((i) => i.id === activeIdStr);
|
||
|
|
if (activeIndex === -1) return prev;
|
||
|
|
|
||
|
|
const [removed] = activeItems.splice(activeIndex, 1);
|
||
|
|
const patched = patchObjectForColumn(removed, overContainer, groupField);
|
||
|
|
let newIndex = overItems.length;
|
||
|
|
if (!(overIdStr in prev)) {
|
||
|
|
const overItemIndex = overItems.findIndex((i) => i.id === overIdStr);
|
||
|
|
if (overItemIndex >= 0) newIndex = overItemIndex;
|
||
|
|
}
|
||
|
|
const nextOver = [...overItems];
|
||
|
|
nextOver.splice(newIndex, 0, patched);
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
[activeContainer]: activeItems,
|
||
|
|
[overContainer]: nextOver,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const list = [...prev[activeContainer]];
|
||
|
|
const oldIndex = list.findIndex((i) => i.id === activeIdStr);
|
||
|
|
|
||
|
|
if (oldIndex === -1) return prev;
|
||
|
|
|
||
|
|
if (overIdStr in prev) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
|
||
|
|
const newIndex = list.findIndex((i) => i.id === overIdStr);
|
||
|
|
if (newIndex === -1 || oldIndex === newIndex) return prev;
|
||
|
|
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
[activeContainer]: arrayMove(list, oldIndex, newIndex),
|
||
|
|
};
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
if (isLoading) {
|
||
|
|
return (
|
||
|
|
<div className={cn("flex flex-1 items-center justify-center p-8", className)}>
|
||
|
|
<p className="text-sm text-muted-foreground">Loading board…</p>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className={cn("flex min-h-0 flex-1 flex-col gap-3", className)}>
|
||
|
|
<div className="shrink-0 px-1 text-xs text-muted-foreground">
|
||
|
|
{total} task{total === 1 ? "" : "s"}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DndContext
|
||
|
|
sensors={sensors}
|
||
|
|
collisionDetection={closestCorners}
|
||
|
|
onDragStart={handleDragStart}
|
||
|
|
onDragOver={handleDragOver}
|
||
|
|
onDragEnd={handleDragEnd}
|
||
|
|
onDragCancel={handleDragCancel}
|
||
|
|
>
|
||
|
|
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-hidden pb-2">
|
||
|
|
<div className="flex h-full min-h-[min(420px,70vh)] gap-3 px-1 pb-1">
|
||
|
|
{columnKeys.map((columnId) => {
|
||
|
|
const theme = getColumnTheme(columnId);
|
||
|
|
return (
|
||
|
|
<BoardColumn
|
||
|
|
key={columnId}
|
||
|
|
columnId={columnId}
|
||
|
|
label={theme.label}
|
||
|
|
items={columns[columnId] ?? []}
|
||
|
|
dotClass={theme.dot}
|
||
|
|
borderTopClass={theme.borderTop}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DragOverlay dropAnimation={null}>
|
||
|
|
{activeObject ? (
|
||
|
|
<div className="w-[min(100%,290px)] min-w-[260px] max-w-[300px] cursor-grabbing">
|
||
|
|
<BoardCardPreview object={activeObject} />
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</DragOverlay>
|
||
|
|
</DndContext>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|