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">
|
<div className="flex items-center gap-2">
|
||||||
<CardAvatars creator={task.creator} assignees={task.assignees} />
|
<CardAvatars creator={task.creator} assignees={task.assignees} />
|
||||||
<CardActions
|
<CardActions
|
||||||
|
taskId={task.id}
|
||||||
currentStatus={task.status}
|
currentStatus={task.status}
|
||||||
isCreator={task.isCreator ?? false}
|
isCreator={task.isCreator ?? false}
|
||||||
onMove={handleMove}
|
onMove={handleMove}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,27 @@
|
||||||
/**
|
/**
|
||||||
* @file dashboard/card/CardActions.tsx
|
* @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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
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";
|
import { COLUMNS, TaskStatus } from "@/types/task";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Properties for the CardActions component.
|
* Properties for the CardActions component.
|
||||||
*
|
*
|
||||||
* @interface CardActionsProps
|
* @interface CardActionsProps
|
||||||
|
* @property {string} taskId - The unique identifier of the task.
|
||||||
* @property {TaskStatus} currentStatus - The current status category 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 {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 {(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.
|
* @property {() => void} onDelete - Callback function triggered when the delete action is selected.
|
||||||
*/
|
*/
|
||||||
interface CardActionsProps {
|
interface CardActionsProps {
|
||||||
|
taskId: string;
|
||||||
currentStatus: TaskStatus;
|
currentStatus: TaskStatus;
|
||||||
isCreator: boolean;
|
isCreator: boolean;
|
||||||
onMove: (newStatus: TaskStatus) => void;
|
onMove: (newStatus: TaskStatus) => void;
|
||||||
|
|
@ -27,17 +30,19 @@ interface CardActionsProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a mobile-only action menu component allowing users to move a task
|
* 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.
|
* @param {CardActionsProps} props - The component props.
|
||||||
* @returns {JSX.Element} The rendered mobile card actions component.
|
* @returns {JSX.Element} The rendered mobile card actions component.
|
||||||
*/
|
*/
|
||||||
export default function CardActions({
|
export default function CardActions({
|
||||||
|
taskId,
|
||||||
currentStatus,
|
currentStatus,
|
||||||
isCreator,
|
isCreator,
|
||||||
onMove,
|
onMove,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: CardActionsProps) {
|
}: CardActionsProps) {
|
||||||
|
const router = useRouter();
|
||||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
const [showMobileActions, setShowMobileActions] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -72,6 +77,19 @@ export default function CardActions({
|
||||||
setShowMobileActions(false);
|
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.
|
* Handles clicking the delete action item to trigger task deletion.
|
||||||
*
|
*
|
||||||
|
|
@ -92,7 +110,7 @@ export default function CardActions({
|
||||||
setShowMobileActions(!showMobileActions);
|
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"
|
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} />
|
<MoreHorizontal size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -124,10 +142,20 @@ export default function CardActions({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Delete Button */}
|
{/* Creator Actions: Edit & Delete */}
|
||||||
{isCreator && (
|
{isCreator && (
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border my-2" />
|
<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
|
<button
|
||||||
onClick={handleDeleteClick}
|
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"
|
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";
|
"use client";
|
||||||
|
|
||||||
import { Sparkles } from "lucide-react";
|
import { Sparkles } from "lucide-react";
|
||||||
import DeleteDropZone from "./DeleteDropZone";
|
|
||||||
import TrashLink from "./TrashLink";
|
import TrashLink from "./TrashLink";
|
||||||
import NewTaskButton from "./NewTaskButton";
|
import NewTaskButton from "./NewTaskButton";
|
||||||
import { useState } from "react";
|
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.
|
* and conditionally displays the new task action button and trash link based on the drag state.
|
||||||
*
|
*
|
||||||
* @param {Object} props - The component props.
|
* @param {Object} props - The component props.
|
||||||
|
|
@ -40,7 +40,7 @@ export default function Header({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<DeleteDropZone
|
<ActionDropZones
|
||||||
onTaskDelete={onTaskDelete}
|
onTaskDelete={onTaskDelete}
|
||||||
onDragStateChange={setIsDraggingActive}
|
onDragStateChange={setIsDraggingActive}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue