feat(dashboard): add individual task loading states and optimize client-server component architecture

This commit is contained in:
Chneemann 2026-08-10 14:19:40 +02:00
parent 68e5070d78
commit c4989155b3
No known key found for this signature in database
5 changed files with 135 additions and 83 deletions

View file

@ -0,0 +1,82 @@
/**
* @file dashboard/KanbanBoard.tsx
* @description Client component wrapping the kanban columns grid, tracking individual task update states, and handling asynchronous status mutations via API.
*/
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import KanbanColumn from "./KanbanColumn";
import { KANBAN_COLUMNS, Task, TaskStatus } from "@/types/tasks";
/**
* Renders the responsive grid container of kanban columns, coordinating state tracking
* for active task updates and triggering status mutation API requests.
*
* @param {Object} props - The component props.
* @param {Task[]} props.tasks - The array of task items displayed across the board.
* @returns {JSX.Element} The rendered kanban board component.
*/
export default function KanbanBoard({ tasks }: { tasks: Task[] }) {
const router = useRouter();
const [, startTransition] = useTransition();
const [updatingTaskIds, setUpdatingTaskIds] = useState<Set<string>>(
new Set(),
);
/**
* Updates the status of a specific task by sending a PATCH request to the API,
* managing loading states, and refreshing the router upon success.
*
* @param {string} taskId - The unique identifier of the task to update.
* @param {TaskStatus} targetStatus - The new target status for the task.
*/
const updateTaskStatus = (taskId: string, targetStatus: TaskStatus) => {
setUpdatingTaskIds((prev) => new Set(prev).add(taskId));
startTransition(async () => {
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: targetStatus }),
});
if (!response.ok) {
throw new Error("Failed to update task status");
}
await router.refresh();
} catch (error) {
console.error("Error during task status update:", error);
} finally {
setUpdatingTaskIds((prev) => {
const next = new Set(prev);
next.delete(taskId);
return next;
});
}
});
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start">
{KANBAN_COLUMNS.map((col) => {
const columnTasks = tasks.filter((t) => t.status === col.id);
return (
<KanbanColumn
key={col.id}
id={col.id}
title={col.title}
count={columnTasks.length}
tasks={columnTasks}
color={col.color}
updatingTaskIds={updatingTaskIds}
onTaskMove={updateTaskStatus}
/>
);
})}
</div>
);
}

View file

@ -1,10 +1,11 @@
/** /**
* @file dashboard/KanbanCard.tsx * @file dashboard/KanbanCard.tsx
* @description Client component rendering a single kanban card container with native drag-and-drop source handlers and modular sub-components. * @description Client component rendering a single kanban card container with individual loading states and modular sub-components.
*/ */
"use client"; "use client";
import { Loader2 } from "lucide-react";
import { KanbanCardProps, TaskStatus } from "@/types/tasks"; import { KanbanCardProps, TaskStatus } from "@/types/tasks";
import KanbanCardActions from "./components/KanbanCardActions"; import KanbanCardActions from "./components/KanbanCardActions";
import KanbanCardAvatars from "./components/KanbanCardAvatars"; import KanbanCardAvatars from "./components/KanbanCardAvatars";
@ -12,47 +13,64 @@ import KanbanCardDueDate from "./components/KanbanCardDueDate";
import KanbanCardPriority from "./components/KanbanCardPriority"; import KanbanCardPriority from "./components/KanbanCardPriority";
/** /**
* Renders an interactive kanban card container holding title, description, * Renders an interactive kanban card container handling drag-and-drop actions, loading states,
* modular priority badges, deadline elements, assignees, and action triggers. * and assembling modular sub-components for priorities, due dates, avatars, and actions.
* *
* @param {KanbanCardProps} props - The component props containing the task object and status change handler. * @param {KanbanCardProps} props - The component props containing the task object, updating status flag, and status change handler.
* @returns {JSX.Element} The rendered kanban card container component. * @returns {JSX.Element} The rendered kanban card component.
*/ */
export default function KanbanCard({ task, onStatusChange }: KanbanCardProps) { export default function KanbanCard({
task,
isUpdating = false,
onStatusChange,
}: KanbanCardProps) {
/** /**
* Initiates the drag action on a task card, storing its ID and current status in the data transfer payload. * Initiates the drag action on a task card if not currently updating, storing its ID and status payload.
* *
* @param {React.DragEvent<HTMLDivElement>} e - The drag event object. * @param {React.DragEvent<HTMLDivElement>} e - The drag event object.
*/ */
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => { const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
if (isUpdating) {
e.preventDefault();
return;
}
e.dataTransfer.setData("text/plain", task.id); e.dataTransfer.setData("text/plain", task.id);
e.dataTransfer.setData("sourceStatus", task.status); e.dataTransfer.setData("sourceStatus", task.status);
e.dataTransfer.effectAllowed = "move"; e.dataTransfer.effectAllowed = "move";
}; };
/** /**
* Triggers the status change callback with the target task ID and new status. * Triggers the status change callback with the target task ID and new status if updates are permitted.
* *
* @param {TaskStatus} newStatus - The target task status to transition to. * @param {TaskStatus} newStatus - The target task status to transition to.
*/ */
const handleMove = (newStatus: TaskStatus) => { const handleMove = (newStatus: TaskStatus) => {
if (onStatusChange) { if (!onStatusChange || isUpdating) return;
onStatusChange(task.id, newStatus); onStatusChange(task.id, newStatus);
}
}; };
return ( return (
<div <div
draggable draggable={!isUpdating}
onDragStart={handleDragStart} onDragStart={handleDragStart}
className="group relative bg-card/40 border border-border/80 hover:border-primary/60 p-4 rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 space-y-3 cursor-grab active:cursor-grabbing hover:-translate-y-0.5 flex flex-col h-full" className={`group relative bg-card/40 border border-border/80 hover:border-primary/60 p-4 rounded-2xl shadow-sm hover:shadow-xl transition-all duration-300 space-y-3 flex flex-col h-full ${
isUpdating
? "opacity-50 pointer-events-none cursor-wait bg-primary/5 border-primary/40 animate-pulse"
: "cursor-grab active:cursor-grabbing hover:-translate-y-0.5"
}`}
> >
{/* Loading Spinner */}
{isUpdating && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/25 backdrop-blur-[0.5px] rounded-2xl">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
)}
{/* --- Card Header --- */} {/* --- Card Header --- */}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<h3 className="font-semibold leading-snug group-hover/card:text-primary transition-colors line-clamp-2"> <h3 className="font-semibold leading-snug group-hover/card:text-primary transition-colors line-clamp-2">
{task.title} {task.title}
</h3> </h3>
{/* Card Priority */}
<KanbanCardPriority priority={task.priority} /> <KanbanCardPriority priority={task.priority} />
</div> </div>
@ -63,10 +81,8 @@ export default function KanbanCard({ task, onStatusChange }: KanbanCardProps) {
{/* --- Card Footer --- */} {/* --- Card Footer --- */}
<div className="flex items-center justify-between pt-3 mt-auto text-xs border-t border-border"> <div className="flex items-center justify-between pt-3 mt-auto text-xs border-t border-border">
{/* Date Badge Component */}
<KanbanCardDueDate task={task} /> <KanbanCardDueDate task={task} />
{/* Avatars & Mobile Switcher */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<KanbanCardAvatars <KanbanCardAvatars
creator={task.creator} creator={task.creator}

View file

@ -1,58 +1,27 @@
/** /**
* @file dashboard/KanbanColumn.tsx * @file dashboard/KanbanColumn.tsx
* @description Client component rendering a single kanban column container supporting drag-and-drop drop targets and status updates. * @description Client component rendering a single kanban column container supporting drag-and-drop drop targets, dynamic status updates, and task lists.
*/ */
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState } from "react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import KanbanCard from "./KanbanCard"; import KanbanCard from "./KanbanCard";
import { KanbanColumnProps, TaskStatus } from "@/types/tasks"; import { KanbanColumnProps, TaskStatus } from "@/types/tasks";
/** /**
* Renders a kanban column with title indicators, task counters, drag-and-drop event handlers, * Renders an individual kanban column with an indicator, title, item counter,
* and lists of nested KanbanCard items. * add-task button, and drag-and-drop target zone containing its associated task cards.
* *
* @param {KanbanColumnProps} props - The component props including column ID, title, count, tasks, and color configuration. * @param {KanbanColumnProps} props - The component props containing column configurations, tasks, and event callbacks.
* @returns {JSX.Element} The rendered kanban column component. * @returns {JSX.Element} The rendered kanban column component.
*/ */
export default function KanbanColumn(props: KanbanColumnProps) { export default function KanbanColumn(props: KanbanColumnProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isDraggingOver, setIsDraggingOver] = useState(false); const [isDraggingOver, setIsDraggingOver] = useState(false);
const indicatorColor = props.color ?? "bg-primary"; const indicatorColor = props.color ?? "bg-primary";
/** /**
* Updates the status of a specific task via a PATCH API request and refreshes the router state. * Handles the drag-over event to allow items to be dropped into the column.
*
* @async
* @param {string} taskId - The unique identifier of the task to update.
* @param {TaskStatus} targetStatus - The destination status to apply to the task.
*/
const updateTaskStatus = (taskId: string, targetStatus: TaskStatus) => {
startTransition(async () => {
try {
const response = await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: targetStatus }),
});
if (!response.ok) {
throw new Error("Failed to update task status");
}
router.refresh();
} catch (error) {
console.error("Error during task status update:", error);
}
});
};
/**
* Handles the drag-over event to allow dropping items onto the column.
* *
* @param {React.DragEvent<HTMLDivElement>} e - The drag event object. * @param {React.DragEvent<HTMLDivElement>} e - The drag event object.
*/ */
@ -63,14 +32,14 @@ export default function KanbanColumn(props: KanbanColumnProps) {
}; };
/** /**
* Handles the drag-leave event when a dragged item leaves the column bounds. * Resets the drag-over state when the dragged element leaves the column boundary.
*/ */
const handleDragLeave = () => { const handleDragLeave = () => {
setIsDraggingOver(false); setIsDraggingOver(false);
}; };
/** /**
* Handles dropping a task card onto the column, triggering status updates if valid. * Handles dropping a task card onto the column, extracting task metadata and triggering the move action.
* *
* @param {React.DragEvent<HTMLDivElement>} e - The drop event object. * @param {React.DragEvent<HTMLDivElement>} e - The drop event object.
*/ */
@ -83,7 +52,7 @@ export default function KanbanColumn(props: KanbanColumnProps) {
if (!taskId || sourceStatus === props.id) return; if (!taskId || sourceStatus === props.id) return;
updateTaskStatus(taskId, props.id); props.onTaskMove?.(taskId, props.id);
}; };
return ( return (
@ -95,7 +64,7 @@ export default function KanbanColumn(props: KanbanColumnProps) {
isDraggingOver isDraggingOver
? "border-primary/80 bg-primary/5 shadow-lg ring-4 ring-primary/10" ? "border-primary/80 bg-primary/5 shadow-lg ring-4 ring-primary/10"
: "border-border/60 shadow-sm" : "border-border/60 shadow-sm"
} ${isPending ? "opacity-60 pointer-events-none" : ""}`} }`}
> >
{/* Column Header */} {/* Column Header */}
<div className="flex items-center justify-between mb-4 pb-2 border-b border-border/40"> <div className="flex items-center justify-between mb-4 pb-2 border-b border-border/40">
@ -126,7 +95,8 @@ export default function KanbanColumn(props: KanbanColumnProps) {
<KanbanCard <KanbanCard
key={task.id} key={task.id}
task={task} task={task}
onStatusChange={updateTaskStatus} isUpdating={props.updatingTaskIds?.has(task.id)}
onStatusChange={props.onTaskMove}
/> />
)) ))
)} )}

View file

@ -1,6 +1,6 @@
/** /**
* @file dashboard/page.tsx * @file dashboard/page.tsx
* @description Server component rendering the main dashboard page, handling authentication, fetching user-related tasks and assignees, and displaying them across kanban columns. * @description Server component rendering the main dashboard page, handling authentication, fetching user-related tasks and assignees, and passing them to the board.
*/ */
import { db } from "@/db"; import { db } from "@/db";
@ -8,12 +8,12 @@ import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
import { eq, inArray, or } from "drizzle-orm"; import { eq, inArray, or } from "drizzle-orm";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import KanbanColumn from "./KanbanColumn"; import KanbanBoard from "./KanbanBoard";
import { KANBAN_COLUMNS, Task } from "@/types/tasks"; import { Task } from "@/types/tasks";
/** /**
* Renders the dashboard page layout with user session validation, task queries, assignee mapping, * Renders the dashboard page component with user session validation,
* and distributes the tasks into respective kanban columns. * database queries for relevant tasks and team assignees, and passes the structured dataset to the Kanban board container.
* *
* @async * @async
* @returns {Promise<JSX.Element>} The rendered dashboard page component. * @returns {Promise<JSX.Element>} The rendered dashboard page component.
@ -26,7 +26,6 @@ export default async function Dashboard() {
const currentUserId = session.user.id; const currentUserId = session.user.id;
// Determine Assigned Task IDs
const assignedTaskRows = await db const assignedTaskRows = await db
.select({ taskId: taskAssigneesTable.taskId }) .select({ taskId: taskAssigneesTable.taskId })
.from(taskAssigneesTable) .from(taskAssigneesTable)
@ -34,7 +33,6 @@ export default async function Dashboard() {
const assignedTaskIds = assignedTaskRows.map((r) => r.taskId); const assignedTaskIds = assignedTaskRows.map((r) => r.taskId);
// Load tasks, including their creators (created by the user OR assigned)
const taskWhereClause = const taskWhereClause =
assignedTaskIds.length > 0 assignedTaskIds.length > 0
? or( ? or(
@ -52,7 +50,6 @@ export default async function Dashboard() {
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id)) .innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
.where(taskWhereClause); .where(taskWhereClause);
// Load all assignees for the loaded tasks in a batch
const allTaskIds = rawTasksWithCreator.map((item) => item.task.id); const allTaskIds = rawTasksWithCreator.map((item) => item.task.id);
const assigneesData = const assigneesData =
@ -67,14 +64,12 @@ export default async function Dashboard() {
.where(inArray(taskAssigneesTable.taskId, allTaskIds)) .where(inArray(taskAssigneesTable.taskId, allTaskIds))
: []; : [];
// Map for high-performance mapping (taskId -> array of emails)
const assigneesMap = new Map<string, string[]>(); const assigneesMap = new Map<string, string[]>();
for (const row of assigneesData) { for (const row of assigneesData) {
const existing = assigneesMap.get(row.taskId) || []; const existing = assigneesMap.get(row.taskId) || [];
assigneesMap.set(row.taskId, [...existing, row.email]); assigneesMap.set(row.taskId, [...existing, row.email]);
} }
// Preparing Tasks
const tasks: Task[] = rawTasksWithCreator.map(({ task, creatorEmail }) => ({ const tasks: Task[] = rawTasksWithCreator.map(({ task, creatorEmail }) => ({
...task, ...task,
creator: creatorEmail, creator: creatorEmail,
@ -85,21 +80,7 @@ export default async function Dashboard() {
return ( return (
<div className="space-y-8 max-w-7xl mx-auto pb-12"> <div className="space-y-8 max-w-7xl mx-auto pb-12">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-start"> <KanbanBoard tasks={tasks} />
{KANBAN_COLUMNS.map((col) => {
const columnTasks = tasks.filter((t) => t.status === col.id);
return (
<KanbanColumn
key={col.id}
id={col.id}
title={col.title}
count={columnTasks.length}
tasks={columnTasks}
color={col.color}
/>
);
})}
</div>
</div> </div>
); );
} }

View file

@ -44,10 +44,13 @@ export interface KanbanColumnConfig {
export interface KanbanColumnProps extends KanbanColumnConfig { export interface KanbanColumnProps extends KanbanColumnConfig {
count: number; count: number;
tasks: Task[]; tasks: Task[];
updatingTaskIds?: Set<string>;
onTaskMove?: (taskId: string, targetStatus: TaskStatus) => void;
} }
export interface KanbanCardProps { export interface KanbanCardProps {
task: Task; task: Task;
isUpdating?: boolean;
onStatusChange?: (taskId: string, newStatus: TaskStatus) => void; onStatusChange?: (taskId: string, newStatus: TaskStatus) => void;
} }