fix(dashboard): restrict task deletion dropzone and mobile actions to creators
This commit is contained in:
parent
b386c2b4c5
commit
8d7a4601af
7 changed files with 99 additions and 53 deletions
|
|
@ -37,6 +37,7 @@ export default function Card({
|
|||
}
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.setData("sourceStatus", task.status);
|
||||
e.dataTransfer.setData("isCreator", String(task.isCreator ?? false));
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
|
|
@ -88,6 +89,7 @@ export default function Card({
|
|||
<CardAvatars creator={task.creator} assignees={task.assignees} />
|
||||
<CardActions
|
||||
currentStatus={task.status}
|
||||
isCreator={task.isCreator ?? false}
|
||||
onMove={handleMove}
|
||||
onDelete={() => onDelete?.(task.id)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@ import { COLUMNS, TaskStatus } from "@/types/tasks";
|
|||
*/
|
||||
export default function CardActions({
|
||||
currentStatus,
|
||||
isCreator,
|
||||
onMove,
|
||||
onDelete,
|
||||
}: {
|
||||
currentStatus: TaskStatus;
|
||||
isCreator: boolean;
|
||||
onMove: (newStatus: TaskStatus) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
|
|
@ -113,18 +115,22 @@ export default function CardActions({
|
|||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="h-px bg-border my-1" />
|
||||
|
||||
{/* Delete 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"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Trash2 size={12} />
|
||||
Delete Task
|
||||
</span>
|
||||
</button>
|
||||
{isCreator && (
|
||||
<>
|
||||
<div className="h-px bg-border my-2" />
|
||||
<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"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Trash2 size={12} />
|
||||
Delete Task
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* @file dashboard/header/DeleteDropZone.tsx
|
||||
* @description Client component rendering an interactive drop zone for deleting tasks during drag-and-drop.
|
||||
* @description Client component rendering an interactive drop zone for deleting tasks during drag-and-drop operations, managing drag state and drop actions.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
|
@ -9,26 +9,49 @@ import { useState, useEffect } from "react";
|
|||
import { Trash2 } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Renders a drop target zone that appears dynamically during drag-and-drop operations,
|
||||
* allowing users to delete tasks by dragging them onto the designated area.
|
||||
* Properties for the DeleteDropZone component.
|
||||
*
|
||||
* @param {Object} props - The component props.
|
||||
* @param {(taskId: string) => void} props.onTaskDelete - Callback triggered when a task is dropped into the delete zone.
|
||||
* @returns {JSX.Element | null} The rendered delete drop zone component, or null if no drag operation is active.
|
||||
* @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,
|
||||
}: {
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
}) {
|
||||
onDragStateChange,
|
||||
}: DeleteDropZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isAllowed, setIsAllowed] = useState(false);
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDragStart = () => setIsDragging(true);
|
||||
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);
|
||||
|
|
@ -38,9 +61,9 @@ export default function DeleteDropZone({
|
|||
window.removeEventListener("dragstart", handleDragStart);
|
||||
window.removeEventListener("dragend", handleDragEnd);
|
||||
};
|
||||
}, []);
|
||||
}, [onDragStateChange]);
|
||||
|
||||
if (!isDragging) return null;
|
||||
if (!isDragging || !isAllowed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -58,7 +81,7 @@ export default function DeleteDropZone({
|
|||
onTaskDelete(taskId);
|
||||
}
|
||||
}}
|
||||
className={`peer 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 ${
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -9,19 +9,28 @@ import { Sparkles } from "lucide-react";
|
|||
import DeleteDropZone from "./DeleteDropZone";
|
||||
import TrashLink from "./TrashLink";
|
||||
import NewTaskButton from "./NewTaskButton";
|
||||
import { useState } from "react";
|
||||
|
||||
/**
|
||||
* Properties for the Header component.
|
||||
*
|
||||
* @interface HeaderProps
|
||||
* @property {(taskId: string) => void} onTaskDelete - Callback function triggered when a task is dropped into the delete zone.
|
||||
*/
|
||||
interface HeaderProps {
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the dashboard header section featuring title text, a task deletion drop zone,
|
||||
* a new task action button, and a link to the trash bin.
|
||||
* and conditionally displays the new task action button and trash link based on the drag state.
|
||||
*
|
||||
* @param {HeaderProps} props - The component props.
|
||||
* @returns {JSX.Element} The rendered dashboard header component.
|
||||
*/
|
||||
export default function Header({
|
||||
onTaskDelete,
|
||||
}: {
|
||||
onTaskDelete: (taskId: string) => void;
|
||||
}) {
|
||||
export default function Header({ onTaskDelete }: HeaderProps) {
|
||||
const [isDraggingActive, setIsDraggingActive] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
|
|
@ -36,11 +45,16 @@ export default function Header({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<DeleteDropZone onTaskDelete={onTaskDelete} />
|
||||
<div className="flex items-center gap-3 peer-not-empty:hidden">
|
||||
<NewTaskButton />
|
||||
<TrashLink />
|
||||
</div>
|
||||
<DeleteDropZone
|
||||
onTaskDelete={onTaskDelete}
|
||||
onDragStateChange={setIsDraggingActive}
|
||||
/>
|
||||
{!isDraggingActive && (
|
||||
<div className="flex items-center gap-3 animate-in fade-in duration-200">
|
||||
<NewTaskButton />
|
||||
<TrashLink />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,17 +4,8 @@
|
|||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { tasksTable, taskAssigneesTable, usersTable } from "@/db/schema";
|
||||
import {
|
||||
and,
|
||||
count,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
or,
|
||||
exists,
|
||||
} from "drizzle-orm";
|
||||
import { taskAssigneesTable, usersTable } from "@/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import Board from "./Board";
|
||||
|
|
@ -65,8 +56,7 @@ export default async function Dashboard() {
|
|||
...task,
|
||||
creator: creatorEmail,
|
||||
assignees: assigneesMap.get(task.id) || [],
|
||||
tags: [],
|
||||
commentsCount: 0,
|
||||
isCreator: task.userId === currentUserId,
|
||||
}));
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { db } from "@/db";
|
||||
import { tasksTable } from "@/db/schema";
|
||||
import { Task, tasksTable, usersTable } from "@/db/schema";
|
||||
import { eq, and, isNotNull } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
|
@ -22,20 +22,31 @@ export default async function TrashPage() {
|
|||
const session = await auth();
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
|
||||
const trashedTasks = await db
|
||||
.select()
|
||||
const currentUserId = session.user.id;
|
||||
|
||||
const rawTrashedTasks = await db
|
||||
.select({
|
||||
task: tasksTable,
|
||||
creatorEmail: usersTable.email,
|
||||
})
|
||||
.from(tasksTable)
|
||||
.innerJoin(usersTable, eq(tasksTable.userId, usersTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(tasksTable.userId, session.user.id),
|
||||
eq(tasksTable.userId, currentUserId),
|
||||
isNotNull(tasksTable.deletedAt),
|
||||
),
|
||||
);
|
||||
|
||||
const tasks: Task[] = rawTrashedTasks.map(({ task }) => ({
|
||||
...task,
|
||||
isCreator: task.userId === currentUserId,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-7xl mx-auto pb-12">
|
||||
<TrashHeader />
|
||||
<TrashList tasks={trashedTasks} />
|
||||
<TrashList tasks={tasks} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ export type TaskPriority = (typeof taskPriorityEnum.enumValues)[number];
|
|||
// ==========================================
|
||||
|
||||
export interface Task extends Omit<DbTask, "dueDate"> {
|
||||
dueDate?: Date | null;
|
||||
assignees?: string[];
|
||||
creator?: string;
|
||||
isCreator?: boolean;
|
||||
}
|
||||
|
||||
export interface RouteContext {
|
||||
|
|
|
|||
Loading…
Reference in a new issue