feat(tasks): add task editing actions, drop zone enhancements, and mobile support
This commit is contained in:
parent
60a40b74b8
commit
12e06468cd
5 changed files with 207 additions and 102 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
170
app/(app)/dashboard/header/ActionDropZones.tsx
Normal file
170
app/(app)/dashboard/header/ActionDropZones.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue