├── .eslintignore
├── .prettierignore
├── src
├── vite-env.d.ts
├── utils
│ ├── logging.ts
│ ├── enums.ts
│ ├── models.ts
│ └── helpers.ts
├── config
│ └── theme.ts
├── main.tsx
├── components
│ ├── AutoResizeTextArea.tsx
│ ├── DarkModeIconButton.tsx
│ ├── Column.tsx
│ └── Task.tsx
├── hooks
│ ├── useColumnDrop.ts
│ ├── useTaskCollection.ts
│ ├── useTaskDragAndDrop.ts
│ └── useColumnTasks.ts
└── App.tsx
├── .prettierrc
├── tsconfig.node.json
├── vite.config.ts
├── .gitignore
├── index.html
├── README.md
├── tsconfig.json
├── .eslintrc.json
├── LICENSE
├── package.json
└── public
└── vite.svg
/.eslintignore:
--------------------------------------------------------------------------------
1 | public
2 | node_modules
3 | src/assets
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | src/assets
3 | public
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "all",
5 | "tabWidth": 2,
6 | "useTabs": false,
7 | "printWidth": 80
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/logging.ts:
--------------------------------------------------------------------------------
1 | export const debug = (msg: string) => {
2 | // if (process.env.NODE_ENV !== 'development') {
3 | // return;
4 | // }
5 |
6 | console.log(msg);
7 | };
8 |
--------------------------------------------------------------------------------
/src/utils/enums.ts:
--------------------------------------------------------------------------------
1 | export enum ColumnType {
2 | TO_DO = 'Todo',
3 | IN_PROGRESS = 'In Progress',
4 | BLOCKED = 'Blocked',
5 | COMPLETED = 'Completed',
6 | }
7 |
8 | export enum ItemType {
9 | TASK = 'Task',
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "Node",
6 | "allowSyntheticDefaultImports": true
7 | },
8 | "include": ["vite.config.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { defineConfig } from 'vite';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | server: {
8 | port: 3000,
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/src/utils/models.ts:
--------------------------------------------------------------------------------
1 | import { ColumnType } from './enums';
2 |
3 | export interface TaskModel {
4 | id: string;
5 | title: string;
6 | column: ColumnType;
7 | color: string;
8 | }
9 |
10 | export interface DragItem {
11 | index: number;
12 | id: TaskModel['id'];
13 | from: ColumnType;
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/src/config/theme.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme } from '@chakra-ui/react';
2 |
3 | // 2. Extend the theme to include custom colors, fonts, etc
4 | const theme = extendTheme({
5 | styles: {
6 | global: (props: { colorMode: string }) => ({
7 | body: {
8 | bg: props.colorMode === 'dark' ? 'gray.800' : 'white',
9 | },
10 | }),
11 | },
12 | });
13 |
14 | export default theme;
15 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | DnD Kanban
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider } from '@chakra-ui/react';
2 | import React from 'react';
3 | import ReactDOM from 'react-dom/client';
4 | import App from './App';
5 | import theme from './config/theme';
6 |
7 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
8 |
9 |
10 |
11 |
12 | ,
13 | );
14 |
--------------------------------------------------------------------------------
/src/components/AutoResizeTextArea.tsx:
--------------------------------------------------------------------------------
1 | import { Textarea, TextareaProps } from '@chakra-ui/react';
2 | import React from 'react';
3 | import ResizeTextarea from 'react-textarea-autosize';
4 |
5 | // eslint-disable-next-line react/display-name
6 | export const AutoResizeTextarea = React.forwardRef<
7 | HTMLTextAreaElement,
8 | TextareaProps
9 | >((props, ref) => {
10 | return ;
11 | });
12 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DnD Kanban Board
2 | ### A modern drag and droppable kanban board.
3 | Build tutorial available on [Youtube](https://www.youtube.com/watch?v=9MKFnOKmihE)
4 |
5 |
6 | 
7 |
8 |
9 |
10 | ## Launching the app
11 |
12 | Development:
13 | 1. `npm install`
14 | 2. `npm run dev`
15 |
16 |
17 | Production:
18 | 1. `npm install`
19 | 2. `npm run build`
20 | 3. `npm run start`
21 |
--------------------------------------------------------------------------------
/src/utils/helpers.ts:
--------------------------------------------------------------------------------
1 | const colors = [
2 | 'red',
3 | 'orange',
4 | 'yellow',
5 | 'green',
6 | 'teal',
7 | 'blue',
8 | 'cyan',
9 | 'purple',
10 | 'pink',
11 | ];
12 |
13 | export function swap(arr: T[], i: number, j: number): T[] {
14 | const copy = [...arr];
15 | const tmp = copy[i];
16 | copy[i] = copy[j];
17 | copy[j] = tmp;
18 | return copy;
19 | }
20 |
21 | export function pickChakraRandomColor(variant = '') {
22 | const color = colors[Math.floor(Math.random() * colors.length)];
23 | return color + variant;
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "references": [{ "path": "./tsconfig.node.json" }]
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/DarkModeIconButton.tsx:
--------------------------------------------------------------------------------
1 | import { MoonIcon, SunIcon } from '@chakra-ui/icons';
2 | import { IconButton, useColorMode } from '@chakra-ui/react';
3 | import React from 'react';
4 |
5 | function DarkModeIconButton({
6 | ...rest
7 | }: React.ComponentPropsWithoutRef) {
8 | const { colorMode, toggleColorMode } = useColorMode();
9 |
10 | const isDark = colorMode === 'dark';
11 |
12 | return (
13 | : }
16 | aria-label={'dark-mode-toggle'}
17 | {...rest}
18 | />
19 | );
20 | }
21 |
22 | export default DarkModeIconButton;
23 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:react/recommended",
9 | "plugin:react-hooks/recommended",
10 | "plugin:@typescript-eslint/recommended"
11 | ],
12 | "overrides": [],
13 | "settings": {
14 | "react": {
15 | "version": "detect"
16 | }
17 | },
18 | "parser": "@typescript-eslint/parser",
19 | "parserOptions": {
20 | "ecmaVersion": "latest",
21 | "sourceType": "module"
22 | },
23 | "plugins": ["react", "react-hooks", "@typescript-eslint"],
24 | "rules": {
25 | "react/react-in-jsx-scope": "off",
26 | "@typescript-eslint/no-unused-vars": "off"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/src/hooks/useColumnDrop.ts:
--------------------------------------------------------------------------------
1 | import { useDrop } from 'react-dnd';
2 | import { ColumnType, ItemType } from '../utils/enums';
3 | import { DragItem, TaskModel } from '../utils/models';
4 |
5 | function useColumnDrop(
6 | column: ColumnType,
7 | handleDrop: (fromColumn: ColumnType, taskId: TaskModel['id']) => void,
8 | ) {
9 | const [{ isOver }, dropRef] = useDrop({
10 | accept: ItemType.TASK,
11 | drop: (dragItem) => {
12 | if (!dragItem || dragItem.from === column) {
13 | return;
14 | }
15 |
16 | handleDrop(dragItem.from, dragItem.id);
17 | },
18 | collect: (monitor) => ({
19 | isOver: monitor.isOver(),
20 | }),
21 | });
22 |
23 | return {
24 | isOver,
25 | dropRef,
26 | };
27 | }
28 |
29 | export default useColumnDrop;
30 |
--------------------------------------------------------------------------------
/src/hooks/useTaskCollection.ts:
--------------------------------------------------------------------------------
1 | import { useLocalStorage } from 'usehooks-ts';
2 |
3 | import { v4 as uuidv4 } from 'uuid';
4 | import { ColumnType } from '../utils/enums';
5 | import { TaskModel } from '../utils/models';
6 |
7 | function useTaskCollection() {
8 | return useLocalStorage<{
9 | [key in ColumnType]: TaskModel[];
10 | }>('tasks', {
11 | Todo: [
12 | {
13 | id: uuidv4(),
14 | column: ColumnType.TO_DO,
15 | title: 'Task 1',
16 | color: 'blue.300',
17 | },
18 | ],
19 | 'In Progress': [
20 | {
21 | id: uuidv4(),
22 | column: ColumnType.IN_PROGRESS,
23 | title: 'Task 2',
24 | color: 'yellow.300',
25 | },
26 | ],
27 | Blocked: [
28 | {
29 | id: uuidv4(),
30 | column: ColumnType.BLOCKED,
31 | title: 'Task 3',
32 | color: 'red.300',
33 | },
34 | ],
35 | Completed: [
36 | {
37 | id: uuidv4(),
38 | column: ColumnType.COMPLETED,
39 | title: 'Task 4',
40 | color: 'green.300',
41 | },
42 | ],
43 | });
44 | }
45 |
46 | export default useTaskCollection;
47 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022-present Gionatha Sturba and other contributors
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import {} from '@chakra-ui/icons';
2 | import { Container, Heading, SimpleGrid } from '@chakra-ui/react';
3 | import { DndProvider } from 'react-dnd';
4 | import { HTML5Backend } from 'react-dnd-html5-backend';
5 | import Column from './components/Column';
6 | import DarkModeIconButton from './components/DarkModeIconButton';
7 | import { ColumnType } from './utils/enums';
8 |
9 | function App() {
10 | return (
11 |
12 |
20 | Welcome to DnD Kanban
21 |
22 |
23 |
24 |
25 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | export default App;
41 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dnd-kanban",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "preview": "vite preview"
10 | },
11 | "dependencies": {
12 | "@chakra-ui/icons": "^2.0.8",
13 | "@chakra-ui/react": "^2.2.6",
14 | "@emotion/react": "^11.10.0",
15 | "@emotion/styled": "^11.10.0",
16 | "framer-motion": "^6.5.1",
17 | "react": "^18.2.0",
18 | "react-dnd": "^16.0.1",
19 | "react-dnd-html5-backend": "^16.0.1",
20 | "react-dom": "^18.2.0",
21 | "react-textarea-autosize": "^8.3.4",
22 | "usehooks-ts": "^2.6.0",
23 | "uuid": "^8.3.2",
24 | "vite-plugin-eslint": "^1.8.0"
25 | },
26 | "devDependencies": {
27 | "@types/node": "^18.7.8",
28 | "@types/react": "^18.0.17",
29 | "@types/react-dom": "^18.0.6",
30 | "@types/uuid": "^8.3.4",
31 | "@typescript-eslint/eslint-plugin": "^5.33.0",
32 | "@typescript-eslint/parser": "^5.33.0",
33 | "@vitejs/plugin-react": "^2.0.1",
34 | "eslint": "^8.22.0",
35 | "eslint-config-airbnb-typescript-prettier": "^5.0.0",
36 | "eslint-plugin-jest": "^26.8.3",
37 | "eslint-plugin-react": "^7.30.1",
38 | "eslint-plugin-react-hooks": "^4.6.0",
39 | "eslint-plugin-unicorn": "^43.0.2",
40 | "lodash": "^4.17.21",
41 | "prettier": "2.7.1",
42 | "typescript": "^4.6.4",
43 | "vite": "^3.0.7"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Column.tsx:
--------------------------------------------------------------------------------
1 | import { AddIcon } from '@chakra-ui/icons';
2 | import {
3 | Badge,
4 | Box,
5 | Heading,
6 | IconButton,
7 | Stack,
8 | useColorModeValue,
9 | } from '@chakra-ui/react';
10 | import useColumnDrop from '../hooks/useColumnDrop';
11 | import useColumnTasks from '../hooks/useColumnTasks';
12 | import { ColumnType } from '../utils/enums';
13 | import Task from './Task';
14 |
15 | const ColumnColorScheme: Record = {
16 | Todo: 'gray',
17 | 'In Progress': 'blue',
18 | Blocked: 'red',
19 | Completed: 'green',
20 | };
21 |
22 | function Column({ column }: { column: ColumnType }) {
23 | const {
24 | tasks,
25 | addEmptyTask,
26 | deleteTask,
27 | dropTaskFrom,
28 | swapTasks,
29 | updateTask,
30 | } = useColumnTasks(column);
31 |
32 | const { dropRef, isOver } = useColumnDrop(column, dropTaskFrom);
33 |
34 | const ColumnTasks = tasks.map((task, index) => (
35 |
43 | ));
44 |
45 | return (
46 |
47 |
48 |
54 | {column}
55 |
56 |
57 | }
69 | />
70 |
83 | {ColumnTasks}
84 |
85 |
86 | );
87 | }
88 |
89 | export default Column;
90 |
--------------------------------------------------------------------------------
/src/components/Task.tsx:
--------------------------------------------------------------------------------
1 | import { DeleteIcon } from '@chakra-ui/icons';
2 | import { Box, IconButton, ScaleFade } from '@chakra-ui/react';
3 | import _ from 'lodash';
4 | import { memo } from 'react';
5 | import { useTaskDragAndDrop } from '../hooks/useTaskDragAndDrop';
6 | import { TaskModel } from '../utils/models';
7 | import { AutoResizeTextarea } from './AutoResizeTextArea';
8 |
9 | type TaskProps = {
10 | index: number;
11 | task: TaskModel;
12 | onUpdate: (id: TaskModel['id'], updatedTask: TaskModel) => void;
13 | onDelete: (id: TaskModel['id']) => void;
14 | onDropHover: (i: number, j: number) => void;
15 | };
16 |
17 | function Task({
18 | index,
19 | task,
20 | onUpdate: handleUpdate,
21 | onDropHover: handleDropHover,
22 | onDelete: handleDelete,
23 | }: TaskProps) {
24 | const { ref, isDragging } = useTaskDragAndDrop(
25 | { task, index: index },
26 | handleDropHover,
27 | );
28 |
29 | const handleTitleChange = (e: React.ChangeEvent) => {
30 | const newTitle = e.target.value;
31 | handleUpdate(task.id, { ...task, title: newTitle });
32 | };
33 |
34 | const handleDeleteClick = () => {
35 | handleDelete(task.id);
36 | };
37 |
38 | return (
39 |
40 |
58 | }
68 | opacity={0}
69 | _groupHover={{
70 | opacity: 1,
71 | }}
72 | onClick={handleDeleteClick}
73 | />
74 |
87 |
88 |
89 | );
90 | }
91 | export default memo(Task, (prev, next) => {
92 | if (
93 | _.isEqual(prev.task, next.task) &&
94 | _.isEqual(prev.index, next.index) &&
95 | prev.onDelete === next.onDelete &&
96 | prev.onDropHover === next.onDropHover &&
97 | prev.onUpdate === next.onUpdate
98 | ) {
99 | return true;
100 | }
101 |
102 | return false;
103 | });
104 |
--------------------------------------------------------------------------------
/src/hooks/useTaskDragAndDrop.ts:
--------------------------------------------------------------------------------
1 | import { useRef } from 'react';
2 | import { useDrag, useDrop, XYCoord } from 'react-dnd';
3 | import { ItemType } from '../utils/enums';
4 | import { DragItem, TaskModel } from '../utils/models';
5 |
6 | export function useTaskDragAndDrop(
7 | { task, index }: { task: TaskModel; index: number },
8 | handleDropHover: (i: number, j: number) => void,
9 | ) {
10 | const ref = useRef(null);
11 |
12 | const [{ isDragging }, drag] = useDrag<
13 | DragItem,
14 | void,
15 | { isDragging: boolean }
16 | >({
17 | item: { from: task.column, id: task.id, index },
18 | type: ItemType.TASK,
19 | collect: (monitor) => ({
20 | isDragging: monitor.isDragging(),
21 | }),
22 | });
23 |
24 | const [_, drop] = useDrop({
25 | accept: ItemType.TASK,
26 | hover: (item, monitor) => {
27 | if (!ref.current) {
28 | return;
29 | }
30 |
31 | // the tasks are not on the same column
32 | if (item.from !== task.column) {
33 | return;
34 | }
35 |
36 | const draggedItemIndex = item.index;
37 | const hoveredItemIndex = index;
38 |
39 | // we are swapping the task with itself
40 | if (draggedItemIndex === hoveredItemIndex) {
41 | return;
42 | }
43 |
44 | const isDraggedItemAboveHovered = draggedItemIndex < hoveredItemIndex;
45 | const isDraggedItemBelowHovered = !isDraggedItemAboveHovered;
46 |
47 | // get mouse coordinatees
48 | const { x: mouseX, y: mouseY } = monitor.getClientOffset() as XYCoord;
49 |
50 | // get hover item rectangle
51 | const hoveredBoundingRect = ref.current.getBoundingClientRect();
52 |
53 | // Get hover item middle height position
54 | const hoveredMiddleHeight =
55 | (hoveredBoundingRect.bottom - hoveredBoundingRect.top) / 2;
56 |
57 | const mouseYRelativeToHovered = mouseY - hoveredBoundingRect.top;
58 | const isMouseYAboveHoveredMiddleHeight =
59 | mouseYRelativeToHovered < hoveredMiddleHeight;
60 | const isMouseYBelowHoveredMiddleHeight =
61 | mouseYRelativeToHovered > hoveredMiddleHeight;
62 |
63 | // Only perform the move when the mouse has crossed half of the items height
64 | // When dragging downwards, only move when the cursor is below 50%
65 | // When dragging upwards, only move when the cursor is above 50%
66 |
67 | if (isDraggedItemAboveHovered && isMouseYAboveHoveredMiddleHeight) {
68 | return;
69 | }
70 |
71 | if (isDraggedItemBelowHovered && isMouseYBelowHoveredMiddleHeight) {
72 | return;
73 | }
74 |
75 | // Time to actually perform the action
76 | handleDropHover(draggedItemIndex, hoveredItemIndex);
77 |
78 | // Note: we're mutating the monitor item here!
79 | // Generally it's better to avoid mutations,
80 | // but it's good here for the sake of performance
81 | // to avoid expensive index searches.
82 | item.index = hoveredItemIndex;
83 | },
84 | });
85 |
86 | drag(drop(ref));
87 |
88 | return {
89 | ref,
90 | isDragging,
91 | };
92 | }
93 |
--------------------------------------------------------------------------------
/src/hooks/useColumnTasks.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react';
2 | import { v4 as uuidv4 } from 'uuid';
3 | import { ColumnType } from '../utils/enums';
4 | import { pickChakraRandomColor, swap } from '../utils/helpers';
5 | import { debug } from '../utils/logging';
6 | import { TaskModel } from '../utils/models';
7 | import useTaskCollection from './useTaskCollection';
8 |
9 | const MAX_TASK_PER_COLUMN = 100;
10 |
11 | function useColumnTasks(column: ColumnType) {
12 | const [tasks, setTasks] = useTaskCollection();
13 |
14 | const columnTasks = tasks[column];
15 |
16 | const addEmptyTask = useCallback(() => {
17 | debug(`Adding new empty task to ${column} column`);
18 | setTasks((allTasks) => {
19 | const columnTasks = allTasks[column];
20 |
21 | if (columnTasks.length > MAX_TASK_PER_COLUMN) {
22 | debug('Too many task!');
23 | return allTasks;
24 | }
25 |
26 | const newColumnTask: TaskModel = {
27 | id: uuidv4(),
28 | title: `New ${column} task`,
29 | color: pickChakraRandomColor('.300'),
30 | column,
31 | };
32 |
33 | return {
34 | ...allTasks,
35 | [column]: [newColumnTask, ...columnTasks],
36 | };
37 | });
38 | }, [column, setTasks]);
39 |
40 | const deleteTask = useCallback(
41 | (id: TaskModel['id']) => {
42 | debug(`Removing task ${id}..`);
43 | setTasks((allTasks) => {
44 | const columnTasks = allTasks[column];
45 | return {
46 | ...allTasks,
47 | [column]: columnTasks.filter((task) => task.id !== id),
48 | };
49 | });
50 | },
51 | [column, setTasks],
52 | );
53 |
54 | const updateTask = useCallback(
55 | (id: TaskModel['id'], updatedTask: Omit, 'id'>) => {
56 | debug(`Updating task ${id} with ${JSON.stringify(updateTask)}`);
57 | setTasks((allTasks) => {
58 | const columnTasks = allTasks[column];
59 | return {
60 | ...allTasks,
61 | [column]: columnTasks.map((task) =>
62 | task.id === id ? { ...task, ...updatedTask } : task,
63 | ),
64 | };
65 | });
66 | },
67 | [column, setTasks],
68 | );
69 |
70 | const dropTaskFrom = useCallback(
71 | (from: ColumnType, id: TaskModel['id']) => {
72 | setTasks((allTasks) => {
73 | const fromColumnTasks = allTasks[from];
74 | const toColumnTasks = allTasks[column];
75 | const movingTask = fromColumnTasks.find((task) => task.id === id);
76 |
77 | console.log(`Moving task ${movingTask?.id} from ${from} to ${column}`);
78 |
79 | if (!movingTask) {
80 | return allTasks;
81 | }
82 |
83 | // remove the task from the original column and copy it within the destination column
84 | return {
85 | ...allTasks,
86 | [from]: fromColumnTasks.filter((task) => task.id !== id),
87 | [column]: [{ ...movingTask, column }, ...toColumnTasks],
88 | };
89 | });
90 | },
91 | [column, setTasks],
92 | );
93 |
94 | const swapTasks = useCallback(
95 | (i: number, j: number) => {
96 | debug(`Swapping task ${i} with ${j} in ${column} column`);
97 | setTasks((allTasks) => {
98 | const columnTasks = allTasks[column];
99 | return {
100 | ...allTasks,
101 | [column]: swap(columnTasks, i, j),
102 | };
103 | });
104 | },
105 | [column, setTasks],
106 | );
107 |
108 | return {
109 | tasks: columnTasks,
110 | addEmptyTask,
111 | updateTask,
112 | dropTaskFrom,
113 | deleteTask,
114 | swapTasks,
115 | };
116 | }
117 |
118 | export default useColumnTasks;
119 |
--------------------------------------------------------------------------------