Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feat] Framer-motion 을 이용한 드래그&드랍 구현 #102

Merged
merged 2 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"framer-motion": "^11.11.17",
"lexorank": "^1.0.5",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
Expand Down
135 changes: 133 additions & 2 deletions apps/client/src/components/Kanban.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { DragEvent, useState } from 'react';
import { useParams } from '@tanstack/react-router';
import { useMutation, UseQueryResult } from '@tanstack/react-query';
import { PlusIcon } from '@radix-ui/react-icons';
Expand Down Expand Up @@ -63,6 +63,111 @@ export default function Kanban({ sections, refetch }: KanbanProps) {
createTaskMutation.mutate({ sectionId, position });
};

// drag&drop
const [activeSectionId, setActiveSectionId] = useState(-1);
const [belowTaskId, setBelowTaskId] = useState(-1);

const { mutate: updateTaskPosition } = useMutation({
mutationFn: async ({
sectionId,
taskId,
position,
}: {
sectionId: number;
taskId: number;
position: string;
}) => {
const payload = {
event: 'UPDATE_POSITION',
sectionId,
taskId,
position,
};

return axios.post(`/api/project/${projectId}/update`, payload, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
onSuccess: async () => {
setActiveSectionId(-1);
setBelowTaskId(-1);
await refetch();
},
onError: (error) => {
console.error('Failed to update task position:', error);
setDialogError('태스크 이동 중 문제가 발생했습니다. 다시 시도해주세요.');
setActiveSectionId(-1);
setActiveSectionId(-1);
},
});

const handleDragOver = (e: DragEvent<HTMLDivElement>, sectionId: number, taskId?: number) => {
e.preventDefault();
setActiveSectionId(sectionId);

if (!taskId) {
setBelowTaskId(-1);
return;
}

setBelowTaskId(taskId);
};

const handleDragLeave = () => {
setBelowTaskId(-1);
setActiveSectionId(-1);
};

const handleDrop = (event: DragEvent<HTMLDivElement>, sectionId: number) => {
const taskId = parseInt(event.dataTransfer.getData('taskId'), 10);

if (taskId === belowTaskId) {
setActiveSectionId(-1);
setBelowTaskId(-1);
return;
}

updateTaskPosition({
sectionId,
taskId,
position: calculatePosition(
sections.find((section) => section.id === sectionId)?.tasks || [],
belowTaskId
),
});
};

const calculatePosition = (tasks: TTask[], belowTaskId: number) => {
if (tasks.length === 0) {
// 빈 섹션이라면 랜덤 값 부여.
return LexoRank.middle().toString();
}

if (belowTaskId === -1) {
// 특정 태스크 위에 드랍하지 않은 경우
return LexoRank.parse(tasks[tasks.length - 1].position)
.genNext()
.toString();
}

const belowTaskIndex = tasks.findIndex((task) => task.id === belowTaskId);
const belowTask = tasks[belowTaskIndex];

if (belowTaskIndex === 0) {
// 첫 번째 태스크 위에 드랍한 경우
return LexoRank.parse(belowTask.position).genPrev().toString();
}

return LexoRank.parse(tasks[belowTaskIndex - 1].position)
.between(LexoRank.parse(belowTask.position))
.toString();
};

const handleDragStart = (event: DragEvent<HTMLDivElement>, sectionId: number, task: TTask) => {
event.dataTransfer.setData('taskId', task.id.toString());
event.dataTransfer.setData('sectionId', sectionId.toString());
};

return (
<>
<div className="flex h-[calc(100vh-110px)] flex-1 flex-row space-x-2 overflow-x-auto p-4">
Expand All @@ -72,10 +177,36 @@ export default function Kanban({ sections, refetch }: KanbanProps) {
section={section}
onCreateTask={handleCreateTask}
isCreatingTask={createTaskMutation.isPending}
onDragOver={(e) =>
handleDragOver(e as unknown as DragEvent<HTMLDivElement>, section.id)
}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e as unknown as DragEvent<HTMLDivElement>, section.id)}
className={
activeSectionId === section.id
? 'border-2 border-blue-500'
: 'border-2 border-transparent'
}
>
{section.tasks.map((task) => (
<KanbanTask key={task.id} task={task} />
<KanbanTask
belowed={task.id === belowTaskId}
handleDragLeave={handleDragLeave}
handleDragOver={(e) =>
handleDragOver(e as unknown as DragEvent<HTMLDivElement>, section.id, task.id)
}
handleDragStart={(e) =>
handleDragStart(e as unknown as DragEvent<HTMLDivElement>, section.id, task)
}
key={task.id}
task={task}
/>
))}
<div
className={`mt-2 h-1 w-full rounded-full bg-blue-500 ${
activeSectionId === section.id && belowTaskId === -1 ? 'opacity-100' : 'opacity-0'
} transition-all`}
/>
</KanbanSection>
))}
<Button type="button" variant="outline" className="h-full w-36" disabled>
Expand Down
19 changes: 16 additions & 3 deletions apps/client/src/components/KanbanSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ReactNode } from 'react';
import { ReactNode, DragEvent } from 'react';
import { HamburgerMenuIcon, PlusIcon, TrashIcon } from '@radix-ui/react-icons';
import { Button } from '@/components/ui/button';
import {
Expand All @@ -20,16 +20,24 @@ interface KanbanSectionProps {
onCreateTask: (sectionId: number) => void;
isCreatingTask: boolean;
children: ReactNode;
onDragOver: (e: DragEvent<HTMLDivElement>) => void;
onDragLeave: () => void;
onDrop: (e: DragEvent<HTMLDivElement>) => void;
className?: string;
}

export function KanbanSection({
section,
onCreateTask,
isCreatingTask,
children,
onDragOver,
onDragLeave,
onDrop,
className,
}: KanbanSectionProps) {
return (
<Section className="flex h-full w-96 flex-shrink-0 flex-col bg-gray-50">
<Section className={`flex h-full w-96 flex-shrink-0 flex-col bg-gray-50 ${className}`}>
<SectionHeader>
<div className="flex items-center">
<SectionTitle className="text-xl">{section.name}</SectionTitle>
Expand Down Expand Up @@ -57,7 +65,12 @@ export function KanbanSection({
</Button>
</SectionMenu>
</SectionHeader>
<SectionContent className="flex flex-1 flex-col gap-2 overflow-y-auto">
<SectionContent
className="flex flex-1 flex-col overflow-y-auto"
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
>
{children}
</SectionContent>
<SectionFooter>
Expand Down
52 changes: 41 additions & 11 deletions apps/client/src/components/KanbanTask.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,53 @@
import { DragEvent } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card.tsx';
import Tag from '@/components/Tag.tsx';
import { TTask } from '@/types';

interface TaskProps {
task: TTask;
handleDragOver: (e: DragEvent<HTMLDivElement>) => void;
handleDragLeave: () => void;
handleDragStart: (e: DragEvent<HTMLDivElement>) => void;
belowed: boolean;
}

function KanbanTask({ task }: TaskProps) {
function KanbanTask({
task,
handleDragOver,
handleDragLeave,
handleDragStart,
belowed,
}: TaskProps) {
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
handleDragOver(e);
};

return (
<Card className="bg-white transition-all duration-300">
<CardHeader className="flex flex-row items-start gap-2">
<CardTitle className="text-md mt-1.5 flex flex-1 break-keep">{task.title}</CardTitle>
<div className="mt-0 inline-flex h-8 w-8 rounded-full bg-amber-200" />
</CardHeader>
<CardContent className="flex gap-1">
<Tag text="Feature" />
<Tag text="FE" className="bg-pink-400" />
</CardContent>
</Card>
<motion.div
layout
layoutId={task.id.toString()}
draggable
onDragStart={(e) => handleDragStart(e as unknown as DragEvent<HTMLDivElement>)}
onDragOver={onDragOver}
onDragLeave={handleDragLeave}
>
<div
className={`my-1 h-1 w-full rounded-full bg-blue-500 ${belowed ? 'opacity-100' : 'opacity-0'} transition-all`}
/>
<Card className="bg-white transition-all duration-300">
<CardHeader className="flex flex-row items-start gap-2">
<CardTitle className="text-md mt-1.5 flex flex-1 break-keep">{task.title}</CardTitle>
<div className="mt-0 inline-flex h-8 w-8 rounded-full bg-amber-200" />
</CardHeader>
<CardContent className="flex gap-1">
<Tag text="Feature" />
<Tag text="FE" className="bg-pink-400" />
</CardContent>
</Card>
</motion.div>
);
}

Expand Down
Loading