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