-
Notifications
You must be signed in to change notification settings - Fork 43
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
8 changed files
with
551 additions
and
1,404 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,270 @@ | ||
import { useMemo, useState } from "react"; | ||
import { | ||
DndContext, | ||
DragEndEvent, | ||
DragOverEvent, | ||
DragOverlay, | ||
DragStartEvent, | ||
MouseSensor, | ||
PointerSensor, | ||
TouchSensor, | ||
useSensor, | ||
useSensors, | ||
} from "@dnd-kit/core"; | ||
import { SortableContext, arrayMove } from "@dnd-kit/sortable"; | ||
import { createPortal } from "react-dom"; | ||
import { IconPlus } from "@tabler/icons-react"; | ||
import { genId } from "utils/helper"; | ||
import { Column, Id, Card, KanbanData } from "./types"; | ||
import ColumnContainer from "./Column"; | ||
import TaskCard from "./Card"; | ||
|
||
interface Props { | ||
initData: KanbanData, | ||
onKanbanChange: (columns: Column[], cards: Card[]) => void; | ||
} | ||
|
||
export default function KanbanBoard({initData, onKanbanChange}: Props) { | ||
const [columns, setColumns] = useState<Column[]>(initData.columns); | ||
const columnsId = useMemo(() => columns.map((col) => col.id), [columns]); | ||
|
||
const [tasks, setTasks] = useState<Card[]>(initData.cards); | ||
|
||
const [activeColumn, setActiveColumn] = useState<Column | null>(null); | ||
const [activeTask, setActiveTask] = useState<Card | null>(null); | ||
|
||
const sensors = useSensors( | ||
useSensor(MouseSensor, { | ||
activationConstraint: { | ||
distance: 10, | ||
}, | ||
}), | ||
useSensor(TouchSensor, { | ||
activationConstraint: { | ||
delay: 250, | ||
tolerance: 5, | ||
}, | ||
}), | ||
useSensor(PointerSensor, { | ||
activationConstraint: { | ||
distance: 10, | ||
}, | ||
}) | ||
); | ||
|
||
function createTask(columnId: Id) { | ||
const newTask: Card = { | ||
id: genId(), | ||
columnId, | ||
content: `Task ${tasks.length + 1}`, | ||
}; | ||
const newTasks = [...tasks, newTask]; | ||
|
||
setTasks(newTasks); | ||
// save to file | ||
onKanbanChange(columns, newTasks); | ||
} | ||
|
||
function deleteTask(id: Id) { | ||
const newTasks = tasks.filter((task) => task.id !== id); | ||
setTasks(newTasks); | ||
// save to file | ||
onKanbanChange(columns, newTasks); | ||
} | ||
|
||
function updateTask(id: Id, content: string) { | ||
const newTasks = tasks.map((task) => { | ||
if (task.id !== id) return task; | ||
return { ...task, content }; | ||
}); | ||
|
||
setTasks(newTasks); | ||
// save to file | ||
onKanbanChange(columns, newTasks); | ||
} | ||
|
||
function createNewColumn() { | ||
const columnToAdd: Column = { | ||
id: genId(), | ||
title: `Column ${columns.length + 1}`, | ||
}; | ||
const newColumns = [...columns, columnToAdd]; | ||
setColumns(newColumns); | ||
// save to file | ||
onKanbanChange(newColumns, tasks); | ||
} | ||
|
||
function deleteColumn(id: Id) { | ||
const filteredColumns = columns.filter((col) => col.id !== id); | ||
setColumns(filteredColumns); | ||
|
||
const newTasks = tasks.filter((t) => t.columnId !== id); | ||
setTasks(newTasks); | ||
// save to file | ||
onKanbanChange(filteredColumns, newTasks); | ||
} | ||
|
||
function updateColumn(id: Id, title: string) { | ||
const newColumns = columns.map((col) => { | ||
if (col.id !== id) return col; | ||
return { ...col, title }; | ||
}); | ||
|
||
setColumns(newColumns); | ||
// save to file | ||
onKanbanChange(newColumns, tasks); | ||
} | ||
|
||
function onDragStart(event: DragStartEvent) { | ||
if (event.active.data.current?.type === "Column") { | ||
setActiveColumn(event.active.data.current.column); | ||
return; | ||
} | ||
|
||
if (event.active.data.current?.type === "Task") { | ||
setActiveTask(event.active.data.current.task); | ||
return; | ||
} | ||
} | ||
|
||
function onDragEnd(event: DragEndEvent) { | ||
setActiveColumn(null); | ||
setActiveTask(null); | ||
|
||
const { active, over } = event; | ||
if (!over) return; | ||
|
||
const activeId = active.id; | ||
const overId = over.id; | ||
|
||
if (activeId === overId) return; | ||
|
||
const isActiveAColumn = active.data.current?.type === "Column"; | ||
if (!isActiveAColumn) return; | ||
|
||
console.log("DRAG END"); | ||
|
||
setColumns((columns) => { | ||
const activeColumnIndex = columns.findIndex((col) => col.id === activeId); | ||
const overColumnIndex = columns.findIndex((col) => col.id === overId); | ||
const newColumns = arrayMove(columns, activeColumnIndex, overColumnIndex); | ||
// save to file | ||
onKanbanChange(newColumns, tasks); | ||
|
||
return newColumns; | ||
}); | ||
} | ||
|
||
function onDragOver(event: DragOverEvent) { | ||
const { active, over } = event; | ||
if (!over) return; | ||
|
||
const activeId = active.id; | ||
const overId = over.id; | ||
|
||
if (activeId === overId) return; | ||
|
||
const isActiveATask = active.data.current?.type === "Task"; | ||
const isOverATask = over.data.current?.type === "Task"; | ||
|
||
if (!isActiveATask) return; | ||
|
||
// Im dropping a Task over another Task | ||
if (isActiveATask && isOverATask) { | ||
setTasks((tasks) => { | ||
const activeIndex = tasks.findIndex((t) => t.id === activeId); | ||
const overIndex = tasks.findIndex((t) => t.id === overId); | ||
|
||
let newTasks = []; | ||
if (tasks[activeIndex].columnId != tasks[overIndex].columnId) { | ||
// Fix introduced after video recording | ||
tasks[activeIndex].columnId = tasks[overIndex].columnId; | ||
newTasks = arrayMove(tasks, activeIndex, overIndex - 1); | ||
} else { | ||
newTasks = arrayMove(tasks, activeIndex, overIndex); | ||
} | ||
|
||
// save to file | ||
onKanbanChange(columns, newTasks); | ||
|
||
return newTasks; | ||
}); | ||
} | ||
|
||
const isOverAColumn = over.data.current?.type === "Column"; | ||
|
||
// Im dropping a Task over a column | ||
if (isActiveATask && isOverAColumn) { | ||
setTasks((tasks) => { | ||
const activeIndex = tasks.findIndex((t) => t.id === activeId); | ||
|
||
tasks[activeIndex].columnId = overId; | ||
console.log("DROPPING TASK OVER COLUMN", { activeIndex }); | ||
const newTasks = arrayMove(tasks, activeIndex, activeIndex); | ||
// save to file | ||
onKanbanChange(columns, newTasks); | ||
|
||
return newTasks; | ||
}); | ||
} | ||
} | ||
|
||
return ( | ||
<div className="flex h-full w-full items-center overflow-x-auto overflow-y-hidden px-4"> | ||
<DndContext | ||
sensors={sensors} | ||
onDragStart={onDragStart} | ||
onDragEnd={onDragEnd} | ||
onDragOver={onDragOver} | ||
> | ||
<div className="m-auto flex gap-4"> | ||
<div className="flex gap-4"> | ||
<SortableContext items={columnsId}> | ||
{columns.map((col) => ( | ||
<ColumnContainer | ||
key={col.id} | ||
column={col} | ||
deleteColumn={deleteColumn} | ||
updateColumn={updateColumn} | ||
createTask={createTask} | ||
deleteTask={deleteTask} | ||
updateTask={updateTask} | ||
tasks={tasks.filter((task) => task.columnId === col.id)} | ||
/> | ||
))} | ||
</SortableContext> | ||
</div> | ||
<button className="btn btn-neutral mt-[3rem]" onClick={createNewColumn} > | ||
<IconPlus />Add Column | ||
</button> | ||
</div> | ||
|
||
{createPortal( | ||
<DragOverlay> | ||
{activeColumn && ( | ||
<ColumnContainer | ||
column={activeColumn} | ||
deleteColumn={deleteColumn} | ||
updateColumn={updateColumn} | ||
createTask={createTask} | ||
deleteTask={deleteTask} | ||
updateTask={updateTask} | ||
tasks={tasks.filter( | ||
(task) => task.columnId === activeColumn.id | ||
)} | ||
/> | ||
)} | ||
{activeTask && ( | ||
<TaskCard | ||
task={activeTask} | ||
deleteTask={deleteTask} | ||
updateTask={updateTask} | ||
/> | ||
)} | ||
</DragOverlay>, | ||
document.body | ||
)} | ||
</DndContext> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,104 @@ | ||
import { useState } from "react"; | ||
import { useSortable } from "@dnd-kit/sortable"; | ||
import { CSS } from "@dnd-kit/utilities"; | ||
import { IconTrash } from "@tabler/icons-react"; | ||
import { Id, Card } from "./types"; | ||
|
||
interface Props { | ||
task: Card; | ||
deleteTask: (id: Id) => void; | ||
updateTask: (id: Id, content: string) => void; | ||
} | ||
|
||
export default function TaskCard({ task, deleteTask, updateTask }: Props) { | ||
const [mouseIsOver, setMouseIsOver] = useState(false); | ||
const [editMode, setEditMode] = useState(true); | ||
|
||
const { | ||
setNodeRef, | ||
attributes, | ||
listeners, | ||
transform, | ||
transition, | ||
isDragging, | ||
} = useSortable({ | ||
id: task.id, | ||
data: { | ||
type: "Task", | ||
task, | ||
}, | ||
disabled: editMode, | ||
}); | ||
|
||
const style = { | ||
transition, | ||
transform: CSS.Transform.toString(transform), | ||
}; | ||
|
||
const toggleEditMode = () => { | ||
setEditMode((prev) => !prev); | ||
setMouseIsOver(false); | ||
}; | ||
|
||
if (isDragging) { | ||
return ( | ||
<div | ||
ref={setNodeRef} | ||
style={style} | ||
className="p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl border-2 border-green-500 cursor-grab relative bg-slate-500" | ||
/> | ||
); | ||
} | ||
|
||
if (editMode) { | ||
return ( | ||
<div | ||
ref={setNodeRef} | ||
style={style} | ||
{...attributes} | ||
{...listeners} | ||
className="p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-green-500 cursor-grab relative" | ||
> | ||
<textarea | ||
className=" h-[90%] w-full resize-none border-none rounded bg-transparent text-white focus:outline-none" | ||
value={task.content} | ||
autoFocus | ||
placeholder="Task content here" | ||
onBlur={toggleEditMode} | ||
onKeyDown={(e) => { | ||
if (e.key === "Enter" && e.shiftKey) { | ||
toggleEditMode(); | ||
} | ||
}} | ||
onChange={(e) => updateTask(task.id, e.target.value)} | ||
/> | ||
</div> | ||
); | ||
} | ||
|
||
return ( | ||
<div | ||
ref={setNodeRef} | ||
style={style} | ||
{...attributes} | ||
{...listeners} | ||
onClick={toggleEditMode} | ||
className="p-2.5 h-[100px] min-h-[100px] items-center flex text-left rounded-xl hover:ring-2 hover:ring-inset hover:ring-green-500 cursor-grab relative bg-slate-500 rounded-xl" | ||
onMouseEnter={() => {setMouseIsOver(true);}} | ||
onMouseLeave={() => {setMouseIsOver(false);}} | ||
> | ||
<p className="my-auto h-[90%] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap no-scollbar"> | ||
{task.content} | ||
</p> | ||
|
||
{mouseIsOver && ( | ||
<button | ||
onClick={() => {deleteTask(task.id);}} | ||
className="stroke-white absolute right-4 top-1/2 -translate-y-1/2 p-2 rounded opacity-60 hover:opacity-100 hover:bg-red-600" | ||
> | ||
<IconTrash /> | ||
</button> | ||
)} | ||
</div> | ||
); | ||
} |
Oops, something went wrong.