feat(tasks): add task editing actions, drop zone enhancements, and mobile support

This commit is contained in:
Chneemann 2026-08-13 12:39:24 +02:00
parent 60a40b74b8
commit 12e06468cd
No known key found for this signature in database
5 changed files with 207 additions and 102 deletions

View file

@ -104,6 +104,7 @@ export default function Card({
<div className="flex items-center gap-2">
<CardAvatars creator={task.creator} assignees={task.assignees} />
<CardActions
taskId={task.id}
currentStatus={task.status}
isCreator={task.isCreator ?? false}
onMove={handleMove}

View file

@ -1,24 +1,27 @@
/**
* @file dashboard/card/CardActions.tsx
* @description Client component rendering the mobile status transition and deletion dropdown for a card.
* @description Client component rendering the mobile status transition, edit option, and deletion dropdown for a card.
*/
"use client";
import { useState, useEffect, useRef } from "react";
import { MoreHorizontal, CornerDownRight, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { MoreHorizontal, CornerDownRight, Trash2, Pencil } from "lucide-react";
import { COLUMNS, TaskStatus } from "@/types/task";
/**
* Properties for the CardActions component.
*
* @interface CardActionsProps
* @property {string} taskId - The unique identifier of the task.
* @property {TaskStatus} currentStatus - The current status category of the task.
* @property {boolean} isCreator - Flag indicating whether the current user is the creator of the task.
* @property {(newStatus: TaskStatus) => void} onMove - Callback function triggered when a new status column is selected.
* @property {() => void} onDelete - Callback function triggered when the delete action is selected.
*/
interface CardActionsProps {
taskId: string;
currentStatus: TaskStatus;
isCreator: boolean;
onMove: (newStatus: TaskStatus) => void;
@ -27,17 +30,19 @@ interface CardActionsProps {
/**
* Renders a mobile-only action menu component allowing users to move a task
* between different columns or delete it entirely via a dropdown interface.
* between different columns, edit it, or delete it entirely via a dropdown interface.
*
* @param {CardActionsProps} props - The component props.
* @returns {JSX.Element} The rendered mobile card actions component.
*/
export default function CardActions({
taskId,
currentStatus,
isCreator,
onMove,
onDelete,
}: CardActionsProps) {
const router = useRouter();
const [showMobileActions, setShowMobileActions] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -72,6 +77,19 @@ export default function CardActions({
setShowMobileActions(false);
};
/**
* Handles clicking the edit action item to navigate to the task edit view if a valid ID exists.
*
* @param {React.MouseEvent} e - The mouse event object.
*/
const handleEditClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (taskId) {
router.push(`/tasks?task=edit&id=${taskId}`);
}
setShowMobileActions(false);
};
/**
* Handles clicking the delete action item to trigger task deletion.
*
@ -92,7 +110,7 @@ export default function CardActions({
setShowMobileActions(!showMobileActions);
}}
className="p-1.5 rounded-lg border border-border bg-background/50 text-foreground-muted transition-all cursor-pointer hover:text-foreground hover:border-primary-hover flex items-center justify-center"
title="Move Task"
title="Task Actions"
>
<MoreHorizontal size={14} />
</button>
@ -124,10 +142,20 @@ export default function CardActions({
);
})}
{/* Delete Button */}
{/* Creator Actions: Edit & Delete */}
{isCreator && (
<>
<div className="h-px bg-border my-2" />
<button
onClick={handleEditClick}
disabled={!taskId}
className="w-full text-left px-3 py-2 rounded-lg hover:bg-primary-hover hover:text-black flex items-center justify-between text-foreground-muted group/edit transition-colors cursor-pointer font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="flex items-center gap-2">
<Pencil size={12} />
Edit Task
</span>
</button>
<button
onClick={handleDeleteClick}
className="w-full text-left px-3 py-2 rounded-lg hover:bg-destructive hover:text-foreground flex items-center justify-between text-destructive group/del transition-colors cursor-pointer font-medium"

View file

@ -0,0 +1,170 @@
/**
* @file dashboard/header/ActionDropZones.tsx
* @description Client component rendering interactive drop zones for editing and deleting tasks during drag-and-drop operations.
*/
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Pencil, Trash2, LucideIcon } from "lucide-react";
/**
* Properties for the ActionDropZones component.
*
* @interface ActionDropZonesProps
* @property {(taskId: string) => void} onTaskDelete - Callback triggered when a task is dropped onto the delete zone.
* @property {(isDragging: boolean) => void} onDragStateChange - Callback notified when global drag state begins or ends.
*/
interface ActionDropZonesProps {
onTaskDelete: (taskId: string) => void;
onDragStateChange: (isDragging: boolean) => void;
}
/**
* Configuration structure for individual drop zones (edit and delete).
*
* @interface ActionDropZoneConfig
* @property {"edit" | "delete"} id - Unique identifier for the drop zone.
* @property {string} label - Text description shown inside the drop zone.
* @property {LucideIcon} icon - Icon component displayed next to the label.
* @property {boolean} isOver - Flag indicating if a dragged element is currently hovering over the zone.
* @property {(val: boolean) => void} setIsOver - State updater for the hover flag.
* @property {(taskId: string) => void} onDropAction - Action executed when a task is dropped onto the zone.
* @property {string} activeStyle - Tailwind styling applied when hovered.
* @property {string} inactiveStyle - Tailwind styling applied when idle.
*/
interface ActionDropZoneConfig {
id: "edit" | "delete";
label: string;
icon: LucideIcon;
isOver: boolean;
setIsOver: (val: boolean) => void;
onDropAction: (taskId: string) => void;
activeStyle: string;
inactiveStyle: string;
}
/**
* Renders interactive drop targets for editing or deleting tasks when a drag action starts,
* conditionally displaying the edit zone only for authorized creators.
*
* @param {ActionDropZonesProps} props - The component props.
* @returns {JSX.Element | null} The rendered drop zones container or null if no drag is active.
*/
export default function ActionDropZones({
onTaskDelete,
onDragStateChange,
}: ActionDropZonesProps) {
const router = useRouter();
const [isDragging, setIsDragging] = useState(false);
const [, setIsCreator] = useState(false);
const [isEditOver, setIsEditOver] = useState(false);
const [isDeleteOver, setIsDeleteOver] = useState(false);
useEffect(() => {
/**
* Handles the global dragstart window event to activate drop zones if authorized.
*
* @param {DragEvent} e - The native drag event.
*/
const handleDragStart = (e: DragEvent) => {
const allowed = e.dataTransfer?.getData("isCreator") === "true";
if (allowed) {
setIsDragging(true);
setIsCreator(allowed);
onDragStateChange(true);
}
};
/**
* Handles the global dragend window event to reset drop zone states.
*/
const handleDragEnd = () => {
setIsDragging(false);
setIsCreator(false);
setIsEditOver(false);
setIsDeleteOver(false);
onDragStateChange(false);
};
window.addEventListener("dragstart", handleDragStart);
window.addEventListener("dragend", handleDragEnd);
return () => {
window.removeEventListener("dragstart", handleDragStart);
window.removeEventListener("dragend", handleDragEnd);
};
}, [onDragStateChange]);
if (!isDragging) return null;
const zones: ActionDropZoneConfig[] = [
{
id: "edit" as const,
label: "Drop to Edit",
icon: Pencil,
isOver: isEditOver,
setIsOver: setIsEditOver,
onDropAction: (taskId: string) =>
router.push(`/tasks?task=edit&id=${taskId}`),
activeStyle:
"bg-primary text-black border-primary scale-110 shadow-lg animate-pulse",
inactiveStyle:
"bg-primary/10 border-primary/40 text-primary scale-100 opacity-90 hover:opacity-100",
},
{
id: "delete" as const,
label: "Drop to Delete",
icon: Trash2,
isOver: isDeleteOver,
setIsOver: setIsDeleteOver,
onDropAction: onTaskDelete,
activeStyle:
"bg-destructive border-destructive scale-110 shadow-lg animate-pulse",
inactiveStyle:
"bg-destructive/10 border-destructive/40 text-destructive scale-100 opacity-90 hover:opacity-100",
},
];
return (
<div className="flex items-center gap-3 animate-in fade-in duration-200">
{zones.map(
({
id,
label,
icon: Icon,
isOver,
setIsOver,
onDropAction,
activeStyle,
inactiveStyle,
}) => (
<div
key={id}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsOver(true);
}}
onDragLeave={() => setIsOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsOver(false);
const taskId = e.dataTransfer.getData("text/plain");
if (taskId) {
onDropAction(taskId);
}
}}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium text-sm border-2 border-dashed cursor-pointer select-none transition-all duration-300 ease-out transform ${
isOver ? activeStyle : inactiveStyle
}`}
>
<Icon size={16} className={isOver ? "animate-bounce" : ""} />
<span>{label}</span>
</div>
),
)}
</div>
);
}

View file

@ -1,94 +0,0 @@
/**
* @file dashboard/header/DeleteDropZone.tsx
* @description Client component rendering an interactive drop zone for deleting tasks during drag-and-drop operations, managing drag state and drop actions.
*/
"use client";
import { useState, useEffect } from "react";
import { Trash2 } from "lucide-react";
/**
* Properties for the DeleteDropZone component.
*
* @interface DeleteDropZoneProps
* @property {(taskId: string) => void} onTaskDelete - Callback triggered when a valid task is dropped onto the delete zone.
* @property {(isDragging: boolean) => void} onDragStateChange - Callback notified when drag status and permissions change globally.
*/
interface DeleteDropZoneProps {
onTaskDelete: (taskId: string) => void;
onDragStateChange: (isDragging: boolean) => void;
}
/**
* Renders a conditional delete drop zone during active drag events, enabling task deletion
* when dropped onto the target area if authorized.
*
* @param {DeleteDropZoneProps} props - The component props.
* @returns {JSX.Element | null} The rendered drop zone component or null if no authorized drag is active.
*/
export default function DeleteDropZone({
onTaskDelete,
onDragStateChange,
}: DeleteDropZoneProps) {
const [isDragging, setIsDragging] = useState(false);
const [isAllowed, setIsAllowed] = useState(false);
const [isOver, setIsOver] = useState(false);
useEffect(() => {
const handleDragStart = (e: DragEvent) => {
const isCreatorString = e.dataTransfer?.getData("isCreator");
const allowed = isCreatorString === "true";
if (allowed) {
setIsDragging(true);
setIsAllowed(true);
onDragStateChange(true);
}
};
const handleDragEnd = () => {
setIsDragging(false);
setIsAllowed(false);
setIsOver(false);
onDragStateChange(false);
};
window.addEventListener("dragstart", handleDragStart);
window.addEventListener("dragend", handleDragEnd);
return () => {
window.removeEventListener("dragstart", handleDragStart);
window.removeEventListener("dragend", handleDragEnd);
};
}, [onDragStateChange]);
if (!isDragging || !isAllowed) return null;
return (
<div
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setIsOver(true);
}}
onDragLeave={() => setIsOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsOver(false);
const taskId = e.dataTransfer.getData("text/plain");
if (taskId) {
onTaskDelete(taskId);
}
}}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl font-medium text-sm border-2 border-dashed cursor-pointer select-none transition-all duration-300 ease-out transform ${
isOver
? "bg-destructive border-destructive scale-110 shadow-lg animate-pulse"
: "bg-destructive/10 border-destructive/40 text-destructive scale-100 opacity-90 hover:opacity-100"
}`}
>
<Trash2 size={16} className={isOver ? "animate-bounce" : ""} />
<span>Drop to Delete</span>
</div>
);
}

View file

@ -6,13 +6,13 @@
"use client";
import { Sparkles } from "lucide-react";
import DeleteDropZone from "./DeleteDropZone";
import TrashLink from "./TrashLink";
import NewTaskButton from "./NewTaskButton";
import { useState } from "react";
import ActionDropZones from "./ActionDropZones";
/**
* Renders the dashboard header section featuring title text, a task deletion drop zone,
* Renders the dashboard header section featuring title text, drop zones,
* and conditionally displays the new task action button and trash link based on the drag state.
*
* @param {Object} props - The component props.
@ -40,7 +40,7 @@ export default function Header({
</div>
<div className="flex items-center gap-3">
<DeleteDropZone
<ActionDropZones
onTaskDelete={onTaskDelete}
onDragStateChange={setIsDraggingActive}
/>