From c4989155b3f0258bb965ad93ac4c198a18a9ce89 Mon Sep 17 00:00:00 2001 From: Chneemann Date: Mon, 10 Aug 2026 14:19:40 +0200 Subject: [PATCH] feat(dashboard): add individual task loading states and optimize client-server component architecture --- app/(app)/dashboard/KanbanBoard.tsx | 82 ++++++++++++++++++++++++++++ app/(app)/dashboard/KanbanCard.tsx | 48 ++++++++++------ app/(app)/dashboard/KanbanColumn.tsx | 54 ++++-------------- app/(app)/dashboard/page.tsx | 31 ++--------- types/tasks.ts | 3 + 5 files changed, 135 insertions(+), 83 deletions(-) create mode 100644 app/(app)/dashboard/KanbanBoard.tsx diff --git a/app/(app)/dashboard/KanbanBoard.tsx b/app/(app)/dashboard/KanbanBoard.tsx new file mode 100644 index 0000000..32636da --- /dev/null +++ b/app/(app)/dashboard/KanbanBoard.tsx @@ -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>( + 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 ( +
+ {KANBAN_COLUMNS.map((col) => { + const columnTasks = tasks.filter((t) => t.status === col.id); + return ( + + ); + })} +
+ ); +} diff --git a/app/(app)/dashboard/KanbanCard.tsx b/app/(app)/dashboard/KanbanCard.tsx index 591e26a..716a755 100644 --- a/app/(app)/dashboard/KanbanCard.tsx +++ b/app/(app)/dashboard/KanbanCard.tsx @@ -1,10 +1,11 @@ /** * @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"; +import { Loader2 } from "lucide-react"; import { KanbanCardProps, TaskStatus } from "@/types/tasks"; import KanbanCardActions from "./components/KanbanCardActions"; import KanbanCardAvatars from "./components/KanbanCardAvatars"; @@ -12,47 +13,64 @@ import KanbanCardDueDate from "./components/KanbanCardDueDate"; import KanbanCardPriority from "./components/KanbanCardPriority"; /** - * Renders an interactive kanban card container holding title, description, - * modular priority badges, deadline elements, assignees, and action triggers. + * Renders an interactive kanban card container handling drag-and-drop actions, loading states, + * 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. - * @returns {JSX.Element} The rendered kanban card container component. + * @param {KanbanCardProps} props - The component props containing the task object, updating status flag, and status change handler. + * @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} e - The drag event object. */ const handleDragStart = (e: React.DragEvent) => { + if (isUpdating) { + e.preventDefault(); + return; + } e.dataTransfer.setData("text/plain", task.id); e.dataTransfer.setData("sourceStatus", task.status); 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. */ const handleMove = (newStatus: TaskStatus) => { - if (onStatusChange) { - onStatusChange(task.id, newStatus); - } + if (!onStatusChange || isUpdating) return; + onStatusChange(task.id, newStatus); }; return (
+ {/* Loading Spinner */} + {isUpdating && ( +
+ +
+ )} + {/* --- Card Header --- */}

{task.title}

- {/* Card Priority */}
@@ -63,10 +81,8 @@ export default function KanbanCard({ task, onStatusChange }: KanbanCardProps) { {/* --- Card Footer --- */}
- {/* Date Badge Component */} - {/* Avatars & Mobile Switcher */}
{ - 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. + * Handles the drag-over event to allow items to be dropped into the column. * * @param {React.DragEvent} 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 = () => { 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} e - The drop event object. */ @@ -83,7 +52,7 @@ export default function KanbanColumn(props: KanbanColumnProps) { if (!taskId || sourceStatus === props.id) return; - updateTaskStatus(taskId, props.id); + props.onTaskMove?.(taskId, props.id); }; return ( @@ -95,7 +64,7 @@ export default function KanbanColumn(props: KanbanColumnProps) { isDraggingOver ? "border-primary/80 bg-primary/5 shadow-lg ring-4 ring-primary/10" : "border-border/60 shadow-sm" - } ${isPending ? "opacity-60 pointer-events-none" : ""}`} + }`} > {/* Column Header */}
@@ -126,7 +95,8 @@ export default function KanbanColumn(props: KanbanColumnProps) { )) )} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 1763efa..f5d6184 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -1,6 +1,6 @@ /** * @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"; @@ -8,12 +8,12 @@ import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema"; import { eq, inArray, or } from "drizzle-orm"; import { auth } from "@/auth"; import { redirect } from "next/navigation"; -import KanbanColumn from "./KanbanColumn"; -import { KANBAN_COLUMNS, Task } from "@/types/tasks"; +import KanbanBoard from "./KanbanBoard"; +import { Task } from "@/types/tasks"; /** - * Renders the dashboard page layout with user session validation, task queries, assignee mapping, - * and distributes the tasks into respective kanban columns. + * Renders the dashboard page component with user session validation, + * database queries for relevant tasks and team assignees, and passes the structured dataset to the Kanban board container. * * @async * @returns {Promise} The rendered dashboard page component. @@ -26,7 +26,6 @@ export default async function Dashboard() { const currentUserId = session.user.id; - // Determine Assigned Task IDs const assignedTaskRows = await db .select({ taskId: taskAssigneesTable.taskId }) .from(taskAssigneesTable) @@ -34,7 +33,6 @@ export default async function Dashboard() { const assignedTaskIds = assignedTaskRows.map((r) => r.taskId); - // Load tasks, including their creators (created by the user OR assigned) const taskWhereClause = assignedTaskIds.length > 0 ? or( @@ -52,7 +50,6 @@ export default async function Dashboard() { .innerJoin(usersTable, eq(tasksTable.userId, usersTable.id)) .where(taskWhereClause); - // Load all assignees for the loaded tasks in a batch const allTaskIds = rawTasksWithCreator.map((item) => item.task.id); const assigneesData = @@ -67,14 +64,12 @@ export default async function Dashboard() { .where(inArray(taskAssigneesTable.taskId, allTaskIds)) : []; - // Map for high-performance mapping (taskId -> array of emails) const assigneesMap = new Map(); for (const row of assigneesData) { const existing = assigneesMap.get(row.taskId) || []; assigneesMap.set(row.taskId, [...existing, row.email]); } - // Preparing Tasks const tasks: Task[] = rawTasksWithCreator.map(({ task, creatorEmail }) => ({ ...task, creator: creatorEmail, @@ -85,21 +80,7 @@ export default async function Dashboard() { return (
-
- {KANBAN_COLUMNS.map((col) => { - const columnTasks = tasks.filter((t) => t.status === col.id); - return ( - - ); - })} -
+
); } diff --git a/types/tasks.ts b/types/tasks.ts index ffbe38b..750237d 100644 --- a/types/tasks.ts +++ b/types/tasks.ts @@ -44,10 +44,13 @@ export interface KanbanColumnConfig { export interface KanbanColumnProps extends KanbanColumnConfig { count: number; tasks: Task[]; + updatingTaskIds?: Set; + onTaskMove?: (taskId: string, targetStatus: TaskStatus) => void; } export interface KanbanCardProps { task: Task; + isUpdating?: boolean; onStatusChange?: (taskId: string, newStatus: TaskStatus) => void; }