From 200d9a6df400bab61bbc63f2a28dc3807da77606 Mon Sep 17 00:00:00 2001 From: Andrej Date: Mon, 20 Jan 2025 00:34:07 +0100 Subject: [PATCH 1/2] feat: :sparkles: initial commit for projects --- apps/api/package.json | 1 + apps/api/src/index.ts | 2 + apps/api/src/task/controllers/create-task.ts | 15 +++ apps/api/src/task/controllers/get-tasks.ts | 41 +++++++ apps/api/src/task/controllers/update-task.ts | 46 ++++++++ apps/api/src/task/index.ts | 72 +++++++++++- apps/web/package.json | 1 + .../web/src/components/kanban-board/board.tsx | 25 +++++ .../web/src/components/kanban-board/index.tsx | 47 ++++---- pnpm-lock.yaml | 105 ++++++++++++++++++ 10 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/task/controllers/create-task.ts create mode 100644 apps/api/src/task/controllers/get-tasks.ts create mode 100644 apps/api/src/task/controllers/update-task.ts create mode 100644 apps/web/src/components/kanban-board/board.tsx diff --git a/apps/api/package.json b/apps/api/package.json index d7dee78..fec703a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,6 +7,7 @@ "dev": "bun run --watch src/index.ts" }, "dependencies": { + "@bogeychan/elysia-logger": "^0.1.7", "@elysiajs/cors": "^1.2.0", "@elysiajs/jwt": "^1.2.0", "@elysiajs/websocket": "^0.2.8", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index c568e06..ef18a92 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,3 +1,4 @@ +import { logger } from "@bogeychan/elysia-logger"; import { cors } from "@elysiajs/cors"; import { Elysia } from "elysia"; import project from "./project"; @@ -9,6 +10,7 @@ import workspace from "./workspace"; const app = new Elysia() .state("userId", "") .use(cors()) + .use(logger()) .use(user) .guard({ async beforeHandle({ store, cookie: { session } }) { diff --git a/apps/api/src/task/controllers/create-task.ts b/apps/api/src/task/controllers/create-task.ts new file mode 100644 index 0000000..2cca5c1 --- /dev/null +++ b/apps/api/src/task/controllers/create-task.ts @@ -0,0 +1,15 @@ +import db from "../../database"; +import { taskTable } from "../../database/schema"; + +async function createTask(body: { + projectId: string; + assigneeId: string; + title: string; + status: string; + dueDate: Date; + description: string; +}) { + return db.insert(taskTable).values(body); +} + +export default createTask; diff --git a/apps/api/src/task/controllers/get-tasks.ts b/apps/api/src/task/controllers/get-tasks.ts new file mode 100644 index 0000000..745d32d --- /dev/null +++ b/apps/api/src/task/controllers/get-tasks.ts @@ -0,0 +1,41 @@ +import { eq } from "drizzle-orm"; +import db from "../../database"; +import { projectTable, taskTable } from "../../database/schema"; + +const DEFAULT_COLUMNS = [ + { id: "to-do", name: "To Do" }, + { id: "in-progress", name: "In Progress" }, + { id: "done", name: "Done" }, +] as const; + +async function getTasks(projectId: string) { + const project = await db.query.projectTable.findFirst({ + where: eq(projectTable.id, projectId), + }); + + if (!project) { + throw new Error("TODO"); + } + + const tasks = await db.query.taskTable.findMany({ + where: eq(taskTable.projectId, projectId), + orderBy: (tasks, { desc }) => [desc(tasks.createdAt)], + }); + + const statuses = [...new Set(tasks.map((task) => task.status))]; + + const columns = DEFAULT_COLUMNS.map((column) => ({ + id: column.id, + name: column.name, + tasks: tasks.filter((task) => task.status === column.id), + })); + + return { + id: project.id, + name: project.name, + workspaceId: project.workspaceId, + columns, + }; +} + +export default getTasks; diff --git a/apps/api/src/task/controllers/update-task.ts b/apps/api/src/task/controllers/update-task.ts new file mode 100644 index 0000000..d2069e5 --- /dev/null +++ b/apps/api/src/task/controllers/update-task.ts @@ -0,0 +1,46 @@ +import { and, eq } from "drizzle-orm"; +import db from "../../database"; +import { taskTable } from "../../database/schema"; + +async function updateTask({ + id, + projectId, + title, + status, + dueDate, + description, +}: { + id: string; + projectId: string; + assigneeId: string; + title: string; + status: string; + dueDate: Date; + description: string; +}) { + const [existingTask] = await db + .select() + .from(taskTable) + .where(and(eq(taskTable.id, id), eq(taskTable.projectId, projectId))); + + const isTaskExisting = Boolean(existingTask); + + if (!isTaskExisting) { + throw new Error("TODO"); + } + + const [updatedWorkspace] = await db + .update(taskTable) + .set({ + title, + description, + status, + dueDate, + }) + .where(and(eq(taskTable.id, id), eq(taskTable.projectId, projectId))) + .returning(); + + return updatedWorkspace; +} + +export default updateTask; diff --git a/apps/api/src/task/index.ts b/apps/api/src/task/index.ts index ac5e1f0..f162d75 100644 --- a/apps/api/src/task/index.ts +++ b/apps/api/src/task/index.ts @@ -1,8 +1,72 @@ -import Elysia from "elysia"; +import Elysia, { t } from "elysia"; +import { taskTable } from "../database/schema"; -const task = new Elysia({ prefix: "/task" }).ws("/", { - message(ws, message) { - ws.send(message); +import { eq } from "drizzle-orm"; +import db from "../database"; +import getTasks from "./controllers/get-tasks"; + +const connections = new Map(); + +const task = new Elysia({ prefix: "/task" }).ws("/:projectId", { + async open(ws) { + const projectId = ws.data.params.projectId; + + // TODO: Improve this + if (projectId === "undefined") { + return null; + } + + if (!connections.has(projectId)) { + connections.set(projectId, new Set()); + } + + connections.get(projectId).add(ws); + + const boardState = await getTasks(projectId); + ws.send(JSON.stringify(boardState)); + }, + async message( + ws, + message: { + type: string; + id: string; + status: string; + }, + ) { + const projectId = ws.data.params.projectId; + + const data = message; + + if (data.type === "UPDATE_TASK") { + // TODO: Make this part of updateTask + await db + .update(taskTable) + .set({ status: data.status }) + .where(eq(taskTable.id, data.id)); + + const clients = connections.get(projectId); + const boardState = await getTasks(projectId); + + if (clients) { + for (const client of clients) { + client.send(JSON.stringify(boardState)); + } + } + } + }, + close(ws) { + const projectId = ws.data.params.projectId; + + const clients = connections.get(projectId); + if (clients) { + clients.delete(ws); + + if (clients.size === 0) { + connections.delete(projectId); + } + } + + console.log(`Client disconnected from project ${projectId}`); }, }); diff --git a/apps/web/package.json b/apps/web/package.json index cdf4803..4f2e848 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.54.2", + "react-use-websocket": "^4.11.1", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "zod": "3.24.1", diff --git a/apps/web/src/components/kanban-board/board.tsx b/apps/web/src/components/kanban-board/board.tsx new file mode 100644 index 0000000..7c35671 --- /dev/null +++ b/apps/web/src/components/kanban-board/board.tsx @@ -0,0 +1,25 @@ +import Column from "./column"; + +// TODO: Remove this component +function Board({ project, selectedProject }) { + return ( +
+
+
+

+ {selectedProject?.name} +

+
+
+ +
+
+ {project.columns.map((column) => ( + + ))} +
+
+
+ ); +} +export default Board; diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index 3881a51..2a16c3e 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -8,11 +8,12 @@ import { type UniqueIdentifier, closestCorners, } from "@dnd-kit/core"; -import { useState } from "react"; -import Column from "./column"; +import { useEffect, useState } from "react"; +import Board from "./board"; import TaskCard from "./task-card"; function KanbanBoard() { + const [webSocket, setWebSocket] = useState(null); const [project, setProject] = useState( generateProject({ projectId: "sample-1", @@ -23,6 +24,22 @@ function KanbanBoard() { const { project: selectedProject } = useProjectStore(); const [activeId, setActiveId] = useState(null); + useEffect(() => { + if (selectedProject) { + // TODO: Clean this up by creating a hook for websockets and using Zustand for projects + const freshWebsocket = new WebSocket( + `http://localhost:1337/task/${selectedProject?.id}`, + ); + setWebSocket(freshWebsocket); + + freshWebsocket.onmessage = (event) => { + console.log("OPENED!", event.data); + + setProject(JSON.parse(event.data)); + }; + } + }, [selectedProject?.id]); + const handleDragStart = (event: DragStartEvent) => { setActiveId(event.active.id); }; @@ -82,6 +99,13 @@ function KanbanBoard() { } else { const updatedTask = { ...task, status: destinationColumn.id }; + webSocket?.send( + JSON.stringify({ + type: "UPDATE_TASK", + ...updatedTask, + }), + ); + if (overId === destinationColumn.id) { updatedColumns[destinationColumnIndex] = { ...destinationColumn, @@ -122,24 +146,7 @@ function KanbanBoard() { onDragStart={handleDragStart} onDragEnd={handleDragEnd} > -
-
-
-

- {selectedProject?.name} -

-
-
- -
-
- {project.columns.map((column) => ( - - ))} -
-
-
- + {activeTask ? (
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76e4864..c04d69c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: apps/api: dependencies: + '@bogeychan/elysia-logger': + specifier: ^0.1.7 + version: 0.1.7(elysia@1.2.10(@sinclair/typebox@0.34.13)(typescript@5.7.3)) '@elysiajs/cors': specifier: ^1.2.0 version: 1.2.0(elysia@1.2.10(@sinclair/typebox@0.34.13)(typescript@5.7.3)) @@ -148,6 +151,9 @@ importers: react-hook-form: specifier: ^7.54.2 version: 7.54.2(react@19.0.0) + react-use-websocket: + specifier: ^4.11.1 + version: 4.11.1 tailwind-merge: specifier: ^2.6.0 version: 2.6.0 @@ -370,6 +376,11 @@ packages: cpu: [x64] os: [win32] + '@bogeychan/elysia-logger@0.1.7': + resolution: {integrity: sha512-q6WfSXNzGwp5pMtpERmDqKY4PmKm8Lvn6Hxr/row6YIGGCiy+pWbuwZlQrsDCo2vIQiynhyqjxnh+NCBz3f9Mw==} + peerDependencies: + elysia: '>= 1.2.2' + '@commitlint/cli@19.6.1': resolution: {integrity: sha512-8hcyA6ZoHwWXC76BoC8qVOSr8xHy00LZhZpauiD0iO0VYbVhMnED0da85lTfIULxl7Lj4c6vZgF0Wu/ed1+jlQ==} engines: {node: '>=v18'} @@ -1832,6 +1843,10 @@ packages: array-ify@1.0.0: resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==} + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + autoprefixer@10.4.20: resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} engines: {node: ^10 || ^12 || >=14} @@ -2202,6 +2217,10 @@ packages: resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} engines: {node: '>=8.6.0'} + fast-redact@3.5.0: + resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} + engines: {node: '>=6'} + fast-uri@3.0.3: resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} @@ -2541,6 +2560,10 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -2589,6 +2612,16 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + + pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + + pino@9.6.0: + resolution: {integrity: sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==} + hasBin: true + pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} @@ -2644,12 +2677,18 @@ packages: engines: {node: '>=14'} hasBin: true + process-warning@4.0.1: + resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} + pump@3.0.2: resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + raikiri@0.0.0-beta.8: resolution: {integrity: sha512-cH/yfvkiGkN8IBB2MkRHikpPurTnd2sMkQ/xtGpXrp3O76P4ppcWPb+86mJaBDzKaclLnSX+9NnT79D7ifH4/w==} @@ -2698,6 +2737,9 @@ packages: '@types/react': optional: true + react-use-websocket@4.11.1: + resolution: {integrity: sha512-39e8mK2a2A1h8uY3ePF45b2q0vwMOmaEy7J5qEhQg4n7vYa5oDLmqutG36kZQgAQ/3KCZS0brlGRbbZJ0+zfKQ==} + react@19.0.0: resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} engines: {node: '>=0.10.0'} @@ -2713,6 +2755,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2752,6 +2798,10 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} @@ -2782,6 +2832,9 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2860,6 +2913,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -3240,6 +3296,11 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true + '@bogeychan/elysia-logger@0.1.7(elysia@1.2.10(@sinclair/typebox@0.34.13)(typescript@5.7.3))': + dependencies: + elysia: 1.2.10(@sinclair/typebox@0.34.13)(typescript@5.7.3) + pino: 9.6.0 + '@commitlint/cli@19.6.1(@types/node@22.10.7)(typescript@5.5.4)': dependencies: '@commitlint/format': 19.5.0 @@ -4416,6 +4477,8 @@ snapshots: array-ify@1.0.0: {} + atomic-sleep@1.0.0: {} + autoprefixer@10.4.20(postcss@8.4.49): dependencies: browserslist: 4.24.3 @@ -4782,6 +4845,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-redact@3.5.0: {} + fast-uri@3.0.3: {} fastq@1.18.0: @@ -5040,6 +5105,8 @@ snapshots: object-hash@3.0.0: {} + on-exit-leak-free@2.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -5082,6 +5149,26 @@ snapshots: pify@2.3.0: {} + pino-abstract-transport@2.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.0.0: {} + + pino@9.6.0: + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.5.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 4.0.1 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + pirates@4.0.6: {} postcss-import@15.1.0(postcss@8.4.49): @@ -5138,6 +5225,8 @@ snapshots: prettier@3.4.2: {} + process-warning@4.0.1: {} + pump@3.0.2: dependencies: end-of-stream: 1.4.4 @@ -5145,6 +5234,8 @@ snapshots: queue-microtask@1.2.3: {} + quick-format-unescaped@4.0.4: {} + raikiri@0.0.0-beta.8: {} rc@1.2.8: @@ -5190,6 +5281,8 @@ snapshots: optionalDependencies: '@types/react': 19.0.7 + react-use-websocket@4.11.1: {} + react@19.0.0: {} read-cache@1.0.0: @@ -5206,6 +5299,8 @@ snapshots: dependencies: picomatch: 2.3.1 + real-require@0.2.0: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -5255,6 +5350,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@2.5.0: {} + scheduler@0.25.0: {} semver@6.3.1: {} @@ -5277,6 +5374,10 @@ snapshots: once: 1.4.0 simple-concat: 1.0.1 + sonic-boom@4.2.0: + dependencies: + atomic-sleep: 1.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5384,6 +5485,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thread-stream@3.1.0: + dependencies: + real-require: 0.2.0 + through@2.3.8: {} tiny-invariant@1.3.3: {} From dcb84754b3bb970415bb7e16200224bef5271823 Mon Sep 17 00:00:00 2001 From: Andrej Date: Sun, 26 Jan 2025 21:42:46 +0100 Subject: [PATCH 2/2] feat: :sparkles: finishing socket communication for tasks --- apps/api/drizzle.config.ts | 2 +- ...ower_man.sql => 0000_powerful_magneto.sql} | 11 +- apps/api/drizzle/0001_mean_ultimo.sql | 67 ---- apps/api/drizzle/meta/0000_snapshot.json | 20 +- apps/api/drizzle/meta/0001_snapshot.json | 375 ------------------ apps/api/drizzle/meta/_journal.json | 11 +- apps/api/src/database/schema.ts | 1 + apps/api/src/index.ts | 2 + .../src/project/controllers/delete-project.ts | 2 +- .../src/project/controllers/update-project.ts | 2 +- apps/api/src/project/index.ts | 4 +- apps/api/src/task/controllers/create-task.ts | 22 +- apps/api/src/task/controllers/get-tasks.ts | 33 +- .../task/controllers/update-task-status.ts | 15 + apps/api/src/task/controllers/update-task.ts | 2 +- apps/api/src/task/index.ts | 113 +++--- .../controllers/get-workspace-users.ts | 34 ++ apps/api/src/workspace-user/index.ts | 13 + .../workspace/controllers/get-workspaces.ts | 21 +- .../workspace/controllers/update-workspace.ts | 2 +- apps/web/package.json | 1 + ...ect-modal.tsx => create-project-modal.tsx} | 6 +- .../sections/projects/empty-project-state.tsx | 105 +++++ .../sections/projects/project-item.tsx | 10 +- .../workspaces/components/add-workspace.tsx | 2 +- ...e-modal.tsx => create-workspace-modal.tsx} | 0 .../workspaces/components/workspace-item.tsx | 2 +- .../workspaces/components/workspace-menu.tsx | 6 +- .../sidebar/sections/workspaces/index.tsx | 4 +- .../kanban-board/column/column-dropzone.tsx | 2 +- .../kanban-board/column/column-header.tsx | 48 ++- .../components/kanban-board/column/index.tsx | 4 +- .../web/src/components/kanban-board/index.tsx | 124 ++---- .../src/components/kanban-board/task-card.tsx | 58 ++- .../src/components/task/create-task-modal.tsx | 224 +++++++++++ apps/web/src/components/ui/alert.tsx | 2 +- apps/web/src/components/ui/avatar.tsx | 2 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/dialog.tsx | 2 +- apps/web/src/components/ui/form.tsx | 2 +- apps/web/src/components/ui/input.tsx | 2 +- apps/web/src/components/ui/label.tsx | 2 +- apps/web/src/components/ui/spinner.tsx | 2 +- apps/web/src/components/ui/tooltip.tsx | 2 +- apps/web/src/constants/descriptions.ts | 14 - .../src/constants/priorities-and-statuses.ts | 2 - apps/web/src/constants/task-titles.ts | 14 - apps/web/src/constants/urls.ts | 2 + apps/web/src/fetchers/task/create-task.ts | 29 ++ .../workspace-user/get-workspace-users.ts | 15 + .../hooks/mutations/task/use-create-task.ts | 37 ++ .../hooks/queries/project/use-get-project.ts | 2 +- .../hooks/queries/project/use-get-projects.ts | 2 +- .../use-get-workspace-users.ts | 11 + apps/web/src/hooks/use-board-websocket.ts | 40 ++ apps/web/src/index.css | 45 ++- apps/web/src/lib/{utils.ts => cn.ts} | 0 apps/web/src/lib/to-kebab-case.ts | 9 + .../web/src/lib/workspace/generate-project.ts | 57 --- .../lib/workspace/generate-random-due-date.ts | 8 - apps/web/src/lib/workspace/generate-task.ts | 30 -- .../workspace/generate-tasks-for-column.ts | 20 - apps/web/src/routes/__root.tsx | 2 +- apps/web/src/routes/dashboard/index.tsx | 2 +- apps/web/src/store/hash.ts | 19 + apps/web/src/types/project/index.ts | 20 + apps/web/src/types/workspace/index.ts | 18 - bun.lockb | Bin 209368 -> 209720 bytes packages/libs/src/eden.ts | 11 +- 69 files changed, 897 insertions(+), 878 deletions(-) rename apps/api/drizzle/{0000_abandoned_power_man.sql => 0000_powerful_magneto.sql} (83%) delete mode 100644 apps/api/drizzle/0001_mean_ultimo.sql delete mode 100644 apps/api/drizzle/meta/0001_snapshot.json create mode 100644 apps/api/src/task/controllers/update-task-status.ts create mode 100644 apps/api/src/workspace-user/controllers/get-workspace-users.ts create mode 100644 apps/api/src/workspace-user/index.ts rename apps/web/src/components/common/sidebar/sections/projects/{add-project-modal.tsx => create-project-modal.tsx} (96%) create mode 100644 apps/web/src/components/common/sidebar/sections/projects/empty-project-state.tsx rename apps/web/src/components/common/sidebar/sections/workspaces/components/{add-workspace-modal.tsx => create-workspace-modal.tsx} (100%) create mode 100644 apps/web/src/components/task/create-task-modal.tsx delete mode 100644 apps/web/src/constants/descriptions.ts delete mode 100644 apps/web/src/constants/priorities-and-statuses.ts delete mode 100644 apps/web/src/constants/task-titles.ts create mode 100644 apps/web/src/constants/urls.ts create mode 100644 apps/web/src/fetchers/task/create-task.ts create mode 100644 apps/web/src/fetchers/workspace-user/get-workspace-users.ts create mode 100644 apps/web/src/hooks/mutations/task/use-create-task.ts create mode 100644 apps/web/src/hooks/queries/workspace-users/use-get-workspace-users.ts create mode 100644 apps/web/src/hooks/use-board-websocket.ts rename apps/web/src/lib/{utils.ts => cn.ts} (100%) create mode 100644 apps/web/src/lib/to-kebab-case.ts delete mode 100644 apps/web/src/lib/workspace/generate-project.ts delete mode 100644 apps/web/src/lib/workspace/generate-random-due-date.ts delete mode 100644 apps/web/src/lib/workspace/generate-task.ts delete mode 100644 apps/web/src/lib/workspace/generate-tasks-for-column.ts create mode 100644 apps/web/src/store/hash.ts diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts index 78dad0d..8571062 100644 --- a/apps/api/drizzle.config.ts +++ b/apps/api/drizzle.config.ts @@ -5,6 +5,6 @@ export default defineConfig({ schema: "./src/database/schema.ts", dialect: "sqlite", dbCredentials: { - url: "file:local.db", + url: "file:kaneo.db", }, }) satisfies Config; diff --git a/apps/api/drizzle/0000_abandoned_power_man.sql b/apps/api/drizzle/0000_powerful_magneto.sql similarity index 83% rename from apps/api/drizzle/0000_abandoned_power_man.sql rename to apps/api/drizzle/0000_powerful_magneto.sql index d57888d..f8b8508 100644 --- a/apps/api/drizzle/0000_abandoned_power_man.sql +++ b/apps/api/drizzle/0000_powerful_magneto.sql @@ -3,7 +3,7 @@ CREATE TABLE `project` ( `workspace_id` text NOT NULL, `name` text NOT NULL, `description` text, - `created_at` integer DEFAULT '"2025-01-18T14:59:51.091Z"' NOT NULL, + `created_at` integer DEFAULT '"2025-01-25T23:50:45.754Z"' NOT NULL, FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE cascade ON DELETE cascade ); --> statement-breakpoint @@ -21,8 +21,9 @@ CREATE TABLE `task` ( `title` text NOT NULL, `description` text, `status` text DEFAULT 'to-do' NOT NULL, + `priority` text DEFAULT 'low', `due_date` integer, - `created_at` integer DEFAULT '"2025-01-18T14:59:51.091Z"' NOT NULL, + `created_at` integer DEFAULT '"2025-01-25T23:50:45.754Z"' NOT NULL, FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE cascade ON DELETE cascade, FOREIGN KEY (`assignee_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade ); @@ -32,7 +33,7 @@ CREATE TABLE `user` ( `name` text NOT NULL, `password` text NOT NULL, `email` text NOT NULL, - `created_at` integer DEFAULT '"2025-01-18T14:59:51.090Z"' NOT NULL + `created_at` integer DEFAULT '"2025-01-25T23:50:45.753Z"' NOT NULL ); --> statement-breakpoint CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint @@ -41,7 +42,7 @@ CREATE TABLE `workspace` ( `name` text NOT NULL, `description` text, `owner_id` text NOT NULL, - `created_at` integer DEFAULT '"2025-01-18T14:59:51.091Z"' NOT NULL, + `created_at` integer DEFAULT '"2025-01-25T23:50:45.754Z"' NOT NULL, FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade ); --> statement-breakpoint @@ -50,7 +51,7 @@ CREATE TABLE `workspace_member` ( `workspace_id` text NOT NULL, `user_id` text NOT NULL, `role` text, - `joined_at` integer DEFAULT '"2025-01-18T14:59:51.091Z"' NOT NULL, + `joined_at` integer DEFAULT '"2025-01-25T23:50:45.754Z"' NOT NULL, FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE cascade ON DELETE cascade, FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade ); diff --git a/apps/api/drizzle/0001_mean_ultimo.sql b/apps/api/drizzle/0001_mean_ultimo.sql deleted file mode 100644 index 9e03e43..0000000 --- a/apps/api/drizzle/0001_mean_ultimo.sql +++ /dev/null @@ -1,67 +0,0 @@ -PRAGMA foreign_keys=OFF;--> statement-breakpoint -CREATE TABLE `__new_project` ( - `id` text PRIMARY KEY NOT NULL, - `workspace_id` text NOT NULL, - `name` text NOT NULL, - `description` text, - `created_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL, - FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE cascade ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_project`("id", "workspace_id", "name", "description", "created_at") SELECT "id", "workspace_id", "name", "description", "created_at" FROM `project`;--> statement-breakpoint -DROP TABLE `project`;--> statement-breakpoint -ALTER TABLE `__new_project` RENAME TO `project`;--> statement-breakpoint -PRAGMA foreign_keys=ON;--> statement-breakpoint -CREATE TABLE `__new_task` ( - `id` text PRIMARY KEY NOT NULL, - `project_id` text NOT NULL, - `assignee_id` text NOT NULL, - `title` text NOT NULL, - `description` text, - `status` text DEFAULT 'to-do' NOT NULL, - `due_date` integer, - `created_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL, - FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON UPDATE cascade ON DELETE cascade, - FOREIGN KEY (`assignee_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_task`("id", "project_id", "assignee_id", "title", "description", "status", "due_date", "created_at") SELECT "id", "project_id", "assignee_id", "title", "description", "status", "due_date", "created_at" FROM `task`;--> statement-breakpoint -DROP TABLE `task`;--> statement-breakpoint -ALTER TABLE `__new_task` RENAME TO `task`;--> statement-breakpoint -CREATE TABLE `__new_user` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `password` text NOT NULL, - `email` text NOT NULL, - `created_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL -); ---> statement-breakpoint -INSERT INTO `__new_user`("id", "name", "password", "email", "created_at") SELECT "id", "name", "password", "email", "created_at" FROM `user`;--> statement-breakpoint -DROP TABLE `user`;--> statement-breakpoint -ALTER TABLE `__new_user` RENAME TO `user`;--> statement-breakpoint -CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint -CREATE TABLE `__new_workspace` ( - `id` text PRIMARY KEY NOT NULL, - `name` text NOT NULL, - `description` text, - `owner_id` text NOT NULL, - `created_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL, - FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_workspace`("id", "name", "description", "owner_id", "created_at") SELECT "id", "name", "description", "owner_id", "created_at" FROM `workspace`;--> statement-breakpoint -DROP TABLE `workspace`;--> statement-breakpoint -ALTER TABLE `__new_workspace` RENAME TO `workspace`;--> statement-breakpoint -CREATE TABLE `__new_workspace_member` ( - `id` text PRIMARY KEY NOT NULL, - `workspace_id` text NOT NULL, - `user_id` text NOT NULL, - `role` text, - `joined_at` integer DEFAULT '"2025-01-25T16:10:08.340Z"' NOT NULL, - FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE cascade ON DELETE cascade, - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE cascade ON DELETE cascade -); ---> statement-breakpoint -INSERT INTO `__new_workspace_member`("id", "workspace_id", "user_id", "role", "joined_at") SELECT "id", "workspace_id", "user_id", "role", "joined_at" FROM `workspace_member`;--> statement-breakpoint -DROP TABLE `workspace_member`;--> statement-breakpoint -ALTER TABLE `__new_workspace_member` RENAME TO `workspace_member`; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0000_snapshot.json b/apps/api/drizzle/meta/0000_snapshot.json index d06e6e5..6d2a10e 100644 --- a/apps/api/drizzle/meta/0000_snapshot.json +++ b/apps/api/drizzle/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "c0af92e1-37ce-46b9-bd70-337eb6049763", + "id": "0bc2ad49-3384-4972-89cf-56af7f84a77f", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "project": { @@ -41,7 +41,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.091Z\"'" + "default": "'\"2025-01-25T23:50:45.754Z\"'" } }, "indexes": {}, @@ -147,6 +147,14 @@ "autoincrement": false, "default": "'to-do'" }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'low'" + }, "due_date": { "name": "due_date", "type": "integer", @@ -160,7 +168,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.091Z\"'" + "default": "'\"2025-01-25T23:50:45.754Z\"'" } }, "indexes": {}, @@ -225,7 +233,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.090Z\"'" + "default": "'\"2025-01-25T23:50:45.753Z\"'" } }, "indexes": { @@ -277,7 +285,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.091Z\"'" + "default": "'\"2025-01-25T23:50:45.754Z\"'" } }, "indexes": {}, @@ -333,7 +341,7 @@ "primaryKey": false, "notNull": true, "autoincrement": false, - "default": "'\"2025-01-18T14:59:51.091Z\"'" + "default": "'\"2025-01-25T23:50:45.754Z\"'" } }, "indexes": {}, diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 62d9ad2..0000000 --- a/apps/api/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,375 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "d77a137d-164b-48be-8ff7-bf1802961d15", - "prevId": "c0af92e1-37ce-46b9-bd70-337eb6049763", - "tables": { - "project": { - "name": "project", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": {}, - "foreignKeys": { - "project_workspace_id_workspace_id_fk": { - "name": "project_workspace_id_workspace_id_fk", - "tableFrom": "project", - "tableTo": "workspace", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "session": { - "name": "session", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": { - "session_user_id_user_id_fk": { - "name": "session_user_id_user_id_fk", - "tableFrom": "session", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "task": { - "name": "task", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "project_id": { - "name": "project_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "assignee_id": { - "name": "assignee_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'to-do'" - }, - "due_date": { - "name": "due_date", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": {}, - "foreignKeys": { - "task_project_id_project_id_fk": { - "name": "task_project_id_project_id_fk", - "tableFrom": "task", - "tableTo": "project", - "columnsFrom": ["project_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - }, - "task_assignee_id_user_id_fk": { - "name": "task_assignee_id_user_id_fk", - "tableFrom": "task", - "tableTo": "user", - "columnsFrom": ["assignee_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "user": { - "name": "user", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "password": { - "name": "password", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": { - "user_email_unique": { - "name": "user_email_unique", - "columns": ["email"], - "isUnique": true - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace": { - "name": "workspace", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "owner_id": { - "name": "owner_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": {}, - "foreignKeys": { - "workspace_owner_id_user_id_fk": { - "name": "workspace_owner_id_user_id_fk", - "tableFrom": "workspace", - "tableTo": "user", - "columnsFrom": ["owner_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "workspace_member": { - "name": "workspace_member", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "role": { - "name": "role", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "joined_at": { - "name": "joined_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false, - "default": "'\"2025-01-25T16:10:08.340Z\"'" - } - }, - "indexes": {}, - "foreignKeys": { - "workspace_member_workspace_id_workspace_id_fk": { - "name": "workspace_member_workspace_id_workspace_id_fk", - "tableFrom": "workspace_member", - "tableTo": "workspace", - "columnsFrom": ["workspace_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - }, - "workspace_member_user_id_user_id_fk": { - "name": "workspace_member_user_id_user_id_fk", - "tableFrom": "workspace_member", - "tableTo": "user", - "columnsFrom": ["user_id"], - "columnsTo": ["id"], - "onDelete": "cascade", - "onUpdate": "cascade" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index b70cf2f..8114680 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "6", - "when": 1737212391097, - "tag": "0000_abandoned_power_man", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1737821408351, - "tag": "0001_mean_ultimo", + "when": 1737849045760, + "tag": "0000_powerful_magneto", "breakpoints": true } ] diff --git a/apps/api/src/database/schema.ts b/apps/api/src/database/schema.ts index 4fe9ddd..f9af7b3 100644 --- a/apps/api/src/database/schema.ts +++ b/apps/api/src/database/schema.ts @@ -101,6 +101,7 @@ export const taskTable = sqliteTable("task", { title: text("title").notNull(), description: text("description"), status: text("status").notNull().default("to-do"), + priority: text("priority").default("low"), dueDate: integer("due_date", { mode: "timestamp" }), createdAt: integer("created_at", { mode: "timestamp" }) .default(new Date()) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 77800f4..4553ed8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,6 +6,7 @@ import task from "./task"; import user from "./user"; import { validateSessionToken } from "./user/controllers/validate-session-token"; import workspace from "./workspace"; +import workspaceUser from "./workspace-user"; const app = new Elysia() .state("userId", "") @@ -41,6 +42,7 @@ const app = new Elysia() .use(workspace) .use(project) .use(task) + .use(workspaceUser) .onError(({ code, error }) => { switch (code) { case "VALIDATION": diff --git a/apps/api/src/project/controllers/delete-project.ts b/apps/api/src/project/controllers/delete-project.ts index 6711435..4d56af0 100644 --- a/apps/api/src/project/controllers/delete-project.ts +++ b/apps/api/src/project/controllers/delete-project.ts @@ -17,7 +17,7 @@ async function deleteProject({ const isProjectExisting = Boolean(existingProject); if (!isProjectExisting) { - throw new Error("TODO"); + throw new Error("Project doesn't exist"); } const [deletedProject] = await db diff --git a/apps/api/src/project/controllers/update-project.ts b/apps/api/src/project/controllers/update-project.ts index 8ed7851..0a11558 100644 --- a/apps/api/src/project/controllers/update-project.ts +++ b/apps/api/src/project/controllers/update-project.ts @@ -19,7 +19,7 @@ async function updateProject({ const isProjectExisting = Boolean(existingProject); if (!isProjectExisting) { - throw new Error("TODO"); + throw new Error("Project doesn't exist"); } const [updatedWorkspace] = await db diff --git a/apps/api/src/project/index.ts b/apps/api/src/project/index.ts index 389a36a..d1c203f 100644 --- a/apps/api/src/project/index.ts +++ b/apps/api/src/project/index.ts @@ -28,7 +28,7 @@ const project = new Elysia({ prefix: "/project" }) return projects; }) .get("/:id", async ({ params: { id }, query: { workspaceId } }) => { - if (!workspaceId) throw new Error("TODO"); + if (!workspaceId) throw new Error("Workspace ID is required"); const project = await getProject({ id, workspaceId }); @@ -51,7 +51,7 @@ const project = new Elysia({ prefix: "/project" }) }, ) .delete("/:id", async ({ params: { id }, query: { workspaceId } }) => { - if (!workspaceId) throw new Error("TODO"); + if (!workspaceId) throw new Error("Workspace ID is required"); const deletedProject = await deleteProject({ id, workspaceId }); diff --git a/apps/api/src/task/controllers/create-task.ts b/apps/api/src/task/controllers/create-task.ts index 2cca5c1..e94fca1 100644 --- a/apps/api/src/task/controllers/create-task.ts +++ b/apps/api/src/task/controllers/create-task.ts @@ -1,15 +1,31 @@ +import { eq } from "drizzle-orm"; import db from "../../database"; -import { taskTable } from "../../database/schema"; +import { taskTable, userTable } from "../../database/schema"; async function createTask(body: { projectId: string; assigneeId: string; title: string; status: string; - dueDate: Date; + dueDate: Date | null; description: string; + priority: string; }) { - return db.insert(taskTable).values(body); + const [assignee] = await db + .select({ name: userTable.name }) + .from(userTable) + .where(eq(userTable.id, body.assigneeId)); + + if (!assignee) { + throw new Error("Assignee not found"); + } + + const [createdTask] = await db.insert(taskTable).values(body).returning(); + + return { + ...createdTask, + assigneeName: assignee.name, + }; } export default createTask; diff --git a/apps/api/src/task/controllers/get-tasks.ts b/apps/api/src/task/controllers/get-tasks.ts index 745d32d..da17aed 100644 --- a/apps/api/src/task/controllers/get-tasks.ts +++ b/apps/api/src/task/controllers/get-tasks.ts @@ -1,10 +1,11 @@ import { eq } from "drizzle-orm"; import db from "../../database"; -import { projectTable, taskTable } from "../../database/schema"; +import { projectTable, taskTable, userTable } from "../../database/schema"; const DEFAULT_COLUMNS = [ { id: "to-do", name: "To Do" }, { id: "in-progress", name: "In Progress" }, + { id: "in-review", name: "In Review" }, { id: "done", name: "Done" }, ] as const; @@ -14,20 +15,34 @@ async function getTasks(projectId: string) { }); if (!project) { - throw new Error("TODO"); + throw new Error("Project not found"); } - const tasks = await db.query.taskTable.findMany({ - where: eq(taskTable.projectId, projectId), - orderBy: (tasks, { desc }) => [desc(tasks.createdAt)], - }); - - const statuses = [...new Set(tasks.map((task) => task.status))]; + const tasks = await db + .select({ + id: taskTable.id, + title: taskTable.title, + description: taskTable.description, + status: taskTable.status, + priority: taskTable.priority, + dueDate: taskTable.dueDate, + createdAt: taskTable.createdAt, + assigneeId: taskTable.assigneeId, + assigneeName: userTable.name, + assigneeEmail: userTable.email, + }) + .from(taskTable) + .leftJoin(userTable, eq(taskTable.assigneeId, userTable.id)) + .where(eq(taskTable.projectId, projectId)); const columns = DEFAULT_COLUMNS.map((column) => ({ id: column.id, name: column.name, - tasks: tasks.filter((task) => task.status === column.id), + tasks: tasks + .filter((task) => task.status === column.id) + .map((task) => ({ + ...task, + })), })); return { diff --git a/apps/api/src/task/controllers/update-task-status.ts b/apps/api/src/task/controllers/update-task-status.ts new file mode 100644 index 0000000..365eded --- /dev/null +++ b/apps/api/src/task/controllers/update-task-status.ts @@ -0,0 +1,15 @@ +import { eq } from "drizzle-orm"; +import db from "../../database"; +import { taskTable } from "../../database/schema"; + +async function updateTaskStatus({ + id, + status, +}: { id: string; status: string }) { + await db + .update(taskTable) + .set({ status: status }) + .where(eq(taskTable.id, id)); +} + +export default updateTaskStatus; diff --git a/apps/api/src/task/controllers/update-task.ts b/apps/api/src/task/controllers/update-task.ts index d2069e5..46c880b 100644 --- a/apps/api/src/task/controllers/update-task.ts +++ b/apps/api/src/task/controllers/update-task.ts @@ -26,7 +26,7 @@ async function updateTask({ const isTaskExisting = Boolean(existingTask); if (!isTaskExisting) { - throw new Error("TODO"); + throw new Error("Task doesn't exist"); } const [updatedWorkspace] = await db diff --git a/apps/api/src/task/index.ts b/apps/api/src/task/index.ts index f162d75..2653c6e 100644 --- a/apps/api/src/task/index.ts +++ b/apps/api/src/task/index.ts @@ -1,73 +1,86 @@ import Elysia, { t } from "elysia"; -import { taskTable } from "../database/schema"; -import { eq } from "drizzle-orm"; -import db from "../database"; +import createTask from "./controllers/create-task"; import getTasks from "./controllers/get-tasks"; +import updateTaskStatus from "./controllers/update-task-status"; const connections = new Map(); -const task = new Elysia({ prefix: "/task" }).ws("/:projectId", { - async open(ws) { - const projectId = ws.data.params.projectId; +const task = new Elysia({ prefix: "/task" }) + .post( + "/create", + async ({ body }) => { + const createdTask = await createTask(body); - // TODO: Improve this - if (projectId === "undefined") { - return null; - } + return createdTask; + }, + { + body: t.Object({ + projectId: t.String(), + assigneeId: t.String(), + title: t.String(), + status: t.String(), + dueDate: t.Date(), + description: t.String(), + priority: t.String(), + }), + }, + ) + .ws("/ws/:projectId", { + async open(ws) { + const projectId = ws.data.params.projectId; - if (!connections.has(projectId)) { - connections.set(projectId, new Set()); - } + if (!connections.has(projectId)) { + connections.set(projectId, new Set()); + } - connections.get(projectId).add(ws); + connections.get(projectId).add(ws); - const boardState = await getTasks(projectId); - ws.send(JSON.stringify(boardState)); - }, - async message( - ws, - message: { - type: string; - id: string; - status: string; + const boardState = await getTasks(projectId); + ws.send(boardState); }, - ) { - const projectId = ws.data.params.projectId; + async message( + ws, + message: { + type: string; + id: string; + status: string; + }, + ) { + const projectId = ws.data.params.projectId; - const data = message; + const { type, id, status } = message; - if (data.type === "UPDATE_TASK") { - // TODO: Make this part of updateTask - await db - .update(taskTable) - .set({ status: data.status }) - .where(eq(taskTable.id, data.id)); + if (type === "UPDATE_TASK") { + await updateTaskStatus({ + id, + status, + }); - const clients = connections.get(projectId); - const boardState = await getTasks(projectId); + const clients = connections.get(projectId); + const boardState = await getTasks(projectId); - if (clients) { - for (const client of clients) { - client.send(JSON.stringify(boardState)); + if (clients) { + for (const client of clients) { + client.send(boardState); + } } } - } - }, - close(ws) { - const projectId = ws.data.params.projectId; + }, + close(ws) { + const projectId = ws.data.params.projectId; - const clients = connections.get(projectId); - if (clients) { - clients.delete(ws); + const clients = connections.get(projectId); + if (clients) { + clients.delete(ws); - if (clients.size === 0) { - connections.delete(projectId); + if (clients.size === 0) { + connections.delete(projectId); + } } - } - console.log(`Client disconnected from project ${projectId}`); - }, -}); + console.log(`Client disconnected from project ${projectId}`); + }, + }); export default task; diff --git a/apps/api/src/workspace-user/controllers/get-workspace-users.ts b/apps/api/src/workspace-user/controllers/get-workspace-users.ts new file mode 100644 index 0000000..8f51f20 --- /dev/null +++ b/apps/api/src/workspace-user/controllers/get-workspace-users.ts @@ -0,0 +1,34 @@ +import { eq } from "drizzle-orm"; +import db from "../../database"; +import { + userTable, + workspaceTable, + workspaceUserTable, +} from "../../database/schema"; + +function getWorkspaceUsers({ workspaceId }: { workspaceId: string }) { + return db + .select({ + userId: userTable.id, + userName: userTable.name, + }) + .from(workspaceTable) + .where(eq(workspaceTable.id, workspaceId)) + .leftJoin( + workspaceUserTable, + eq(workspaceTable.id, workspaceUserTable.workspaceId), + ) + .leftJoin(userTable, eq(workspaceUserTable.userId, userTable.id)) + .unionAll( + db + .select({ + userId: userTable.id, + userName: userTable.name, + }) + .from(workspaceTable) + .leftJoin(userTable, eq(workspaceTable.ownerId, userTable.id)) + .where(eq(workspaceTable.id, workspaceId)), + ); +} + +export default getWorkspaceUsers; diff --git a/apps/api/src/workspace-user/index.ts b/apps/api/src/workspace-user/index.ts new file mode 100644 index 0000000..e84a1b6 --- /dev/null +++ b/apps/api/src/workspace-user/index.ts @@ -0,0 +1,13 @@ +import Elysia from "elysia"; +import getWorkspaceUsers from "./controllers/get-workspace-users"; + +const workspaceUser = new Elysia({ prefix: "/workspace-user" }).get( + "/list/:workspaceId", + async ({ params: { workspaceId } }) => { + const workspaceUsersInWorkspace = await getWorkspaceUsers({ workspaceId }); + + return workspaceUsersInWorkspace; + }, +); + +export default workspaceUser; diff --git a/apps/api/src/workspace/controllers/get-workspaces.ts b/apps/api/src/workspace/controllers/get-workspaces.ts index c876598..e4730a5 100644 --- a/apps/api/src/workspace/controllers/get-workspaces.ts +++ b/apps/api/src/workspace/controllers/get-workspaces.ts @@ -7,26 +7,37 @@ import { } from "../../database/schema"; async function getWorkspaces({ userId }: { userId: string }) { - const workspacesWithProjects = await db + const workspaces = await db .select({ id: workspaceTable.id, name: workspaceTable.name, ownerId: workspaceTable.ownerId, - projectId: projectTable.id, - projectName: projectTable.name, }) .from(workspaceTable) .leftJoin( workspaceUserTable, eq(workspaceTable.id, workspaceUserTable.workspaceId), ) - .leftJoin(projectTable, eq(workspaceTable.id, projectTable.workspaceId)) .where( or( eq(workspaceTable.ownerId, userId), eq(workspaceUserTable.userId, userId), ), - ); + ) + .groupBy(workspaceTable.id, workspaceTable.name, workspaceTable.ownerId); + + const workspacesWithProjects = await Promise.all( + workspaces.map(async (workspace) => ({ + ...workspace, + projects: await db + .select({ + id: projectTable.id, + name: projectTable.name, + }) + .from(projectTable) + .where(eq(projectTable.workspaceId, workspace.id)), + })), + ); return workspacesWithProjects; } diff --git a/apps/api/src/workspace/controllers/update-workspace.ts b/apps/api/src/workspace/controllers/update-workspace.ts index 82e3ff9..623fa95 100644 --- a/apps/api/src/workspace/controllers/update-workspace.ts +++ b/apps/api/src/workspace/controllers/update-workspace.ts @@ -25,7 +25,7 @@ async function updateWorkspace({ const isWorkspaceExisting = Boolean(existingWorkspace); if (!isWorkspaceExisting) { - throw new Error("TODO"); + throw new Error("Workspace doesn't exist"); } const updatedWorkspace = await db diff --git a/apps/web/package.json b/apps/web/package.json index 167e2c9..a6c8204 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.0.1", + "immer": "^10.1.1", "lucide-react": "^0.474.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/apps/web/src/components/common/sidebar/sections/projects/add-project-modal.tsx b/apps/web/src/components/common/sidebar/sections/projects/create-project-modal.tsx similarity index 96% rename from apps/web/src/components/common/sidebar/sections/projects/add-project-modal.tsx rename to apps/web/src/components/common/sidebar/sections/projects/create-project-modal.tsx index 525d249..4220d84 100644 --- a/apps/web/src/components/common/sidebar/sections/projects/add-project-modal.tsx +++ b/apps/web/src/components/common/sidebar/sections/projects/create-project-modal.tsx @@ -7,12 +7,12 @@ import { useQueryClient } from "@tanstack/react-query"; import { X } from "lucide-react"; import { useState } from "react"; -type AddProjectModalProps = { +type CreateProjectModalProps = { open: boolean; onClose: () => void; }; -function AddProjectModal({ open, onClose }: AddProjectModalProps) { +function CreateProjectModal({ open, onClose }: CreateProjectModalProps) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const queryClient = useQueryClient(); @@ -108,4 +108,4 @@ function AddProjectModal({ open, onClose }: AddProjectModalProps) { ); } -export default AddProjectModal; +export default CreateProjectModal; diff --git a/apps/web/src/components/common/sidebar/sections/projects/empty-project-state.tsx b/apps/web/src/components/common/sidebar/sections/projects/empty-project-state.tsx new file mode 100644 index 0000000..323e316 --- /dev/null +++ b/apps/web/src/components/common/sidebar/sections/projects/empty-project-state.tsx @@ -0,0 +1,105 @@ +import { Button } from "@/components/ui/button"; +import useGetWorkspaces from "@/hooks/queries/workspace/use-get-workspace"; +import { motion } from "framer-motion"; +import { Layout, Plus } from "lucide-react"; +import { useState } from "react"; +import { CreateWorkspaceModal } from "../workspaces/components/create-workspace-modal"; +import CreateProjectModal from "./create-project-modal"; + +export function BoardEmptyState() { + const { data: workspaces } = useGetWorkspaces(); + const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false); + const [isCreateProjectOpen, setIsCreateProjectOpen] = useState(false); + + return ( +
+ +
+
+
+
+ +
+
+ +

+ {workspaces?.data && workspaces?.data.length === 0 + ? "No Workspace Selected" + : "No Project Selected"} +

+ +

+ {workspaces?.data && workspaces?.data.length === 0 + ? "Get started by creating your first workspace and organizing your projects." + : "Select a project from the sidebar or create a new one to get started."} +

+ +
+ {workspaces?.data && workspaces?.data.length === 0 ? ( + + ) : ( + <> + + + )} +
+ +
+

+ Quick Tips +

+
+
+

+ + Create workspaces + {" "} + to organize different areas of work +

+
+
+

+ + Add projects + {" "} + to track specific initiatives or goals +

+
+
+

+ + Invite team members + {" "} + to collaborate on tasks together +

+
+
+
+ + setIsCreateWorkspaceOpen(false)} + /> + setIsCreateProjectOpen(false)} + /> +
+ ); +} diff --git a/apps/web/src/components/common/sidebar/sections/projects/project-item.tsx b/apps/web/src/components/common/sidebar/sections/projects/project-item.tsx index 6e3884b..7d8e037 100644 --- a/apps/web/src/components/common/sidebar/sections/projects/project-item.tsx +++ b/apps/web/src/components/common/sidebar/sections/projects/project-item.tsx @@ -1,5 +1,5 @@ import useGetProject from "@/hooks/queries/project/use-get-project"; -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/cn"; import useProjectStore from "@/store/project"; import { Layout } from "lucide-react"; import { useEffect } from "react"; @@ -21,10 +21,16 @@ function ProjectItem({ workspaceId, projectId }: ProjectItemProps) { } }, [data, setProject, isSelected]); + const handleSelectProject = () => { + if (!isSelected) { + setProject(data); + } + }; + return (
- -
+ ); } diff --git a/apps/web/src/components/kanban-board/column/index.tsx b/apps/web/src/components/kanban-board/column/index.tsx index 36792a7..4b8b071 100644 --- a/apps/web/src/components/kanban-board/column/index.tsx +++ b/apps/web/src/components/kanban-board/column/index.tsx @@ -1,4 +1,4 @@ -import type { Column as ColumnType } from "@/types/workspace"; +import type { Column as ColumnType } from "@/types/project"; import { ColumnDropzone } from "./column-dropzone"; import { ColumnHeader } from "./column-header"; @@ -8,7 +8,7 @@ interface ColumnProps { function Column({ column }: ColumnProps) { return ( -
+
diff --git a/apps/web/src/components/kanban-board/index.tsx b/apps/web/src/components/kanban-board/index.tsx index 9d71cd0..25f6413 100644 --- a/apps/web/src/components/kanban-board/index.tsx +++ b/apps/web/src/components/kanban-board/index.tsx @@ -1,4 +1,4 @@ -import generateProject from "@/lib/workspace/generate-project"; +import useBoardWebsocket from "@/hooks/use-board-websocket"; import useProjectStore from "@/store/project"; import { DndContext, @@ -8,132 +8,71 @@ import { type UniqueIdentifier, closestCorners, } from "@dnd-kit/core"; -import { useEffect, useState } from "react"; +import { useSearch } from "@tanstack/react-router"; +import { produce } from "immer"; +import { useState } from "react"; +import { BoardEmptyState } from "../common/sidebar/sections/projects/empty-project-state"; import Column from "./column"; import TaskCard from "./task-card"; function KanbanBoard() { - const [webSocket, setWebSocket] = useState(null); - const [project, setProject] = useState( - generateProject({ - projectId: "sample-1", - workspaceId: "workspace-1", - tasksPerColumn: 4, - }), - ); - const { project: selectedProject } = useProjectStore(); + const { project, setProject } = useProjectStore(); const [activeId, setActiveId] = useState(null); - - useEffect(() => { - if (selectedProject) { - // TODO: Clean this up by creating a hook for websockets and using Zustand for projects - const freshWebsocket = new WebSocket( - `http://localhost:1337/task/${selectedProject?.id}`, - ); - setWebSocket(freshWebsocket); - - freshWebsocket.onmessage = (event) => { - console.log("OPENED!", event.data); - - setProject(JSON.parse(event.data)); - }; - } - }, [selectedProject]); + const { ws } = useBoardWebsocket(); const handleDragStart = (event: DragStartEvent) => { setActiveId(event.active.id); }; - // TODO: Simplify this function const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; - - if (!over) return; + if (!over || !project?.columns) return; const activeId = active.id.toString(); const overId = over.id.toString(); - const sourceColumn = project.columns.find((col) => - col.tasks.some((task) => task.id === activeId), - ); - - const destinationColumn = project.columns.find((col) => { - if (col.id === overId) return true; - return col.tasks.some((task) => task.id === overId); - }); - - if (!sourceColumn || !destinationColumn) return; - - setProject((currentProject) => { - const updatedColumns = [...currentProject.columns]; - - const sourceColumnIndex = updatedColumns.findIndex( - (col) => col.id === sourceColumn.id, + const updatedProject = produce(project, (draft) => { + const sourceColumn = draft?.columns?.find((col) => + col.tasks.some((task) => task.id === activeId), ); - const destinationColumnIndex = updatedColumns.findIndex( - (col) => col.id === destinationColumn.id, + const destinationColumn = draft?.columns?.find( + (col) => + col.id === overId || col.tasks.some((task) => task.id === overId), ); + if (!sourceColumn || !destinationColumn) return; + const sourceTaskIndex = sourceColumn.tasks.findIndex( (task) => task.id === activeId, ); const task = sourceColumn.tasks[sourceTaskIndex]; - updatedColumns[sourceColumnIndex] = { - ...sourceColumn, - tasks: sourceColumn.tasks.filter((t) => t.id !== activeId), - }; + sourceColumn.tasks = sourceColumn.tasks.filter((t) => t.id !== activeId); if (sourceColumn.id === destinationColumn.id) { const destinationIndex = destinationColumn.tasks.findIndex( - (task) => task.id === overId, + (t) => t.id === overId, ); - const newTasks = [...destinationColumn.tasks]; - newTasks.splice(sourceTaskIndex, 1); - newTasks.splice(destinationIndex, 0, task); - - updatedColumns[destinationColumnIndex] = { - ...destinationColumn, - tasks: newTasks, - }; + destinationColumn.tasks.splice(destinationIndex, 0, task); } else { const updatedTask = { ...task, status: destinationColumn.id }; + ws?.send(JSON.stringify({ type: "UPDATE_TASK", ...updatedTask })); - webSocket?.send( - JSON.stringify({ - type: "UPDATE_TASK", - ...updatedTask, - }), - ); + const destinationIndex = + overId === destinationColumn.id + ? destinationColumn.tasks.length + : destinationColumn.tasks.findIndex((t) => t.id === overId); - if (overId === destinationColumn.id) { - updatedColumns[destinationColumnIndex] = { - ...destinationColumn, - tasks: [...destinationColumn.tasks, updatedTask], - }; - } else { - const destinationIndex = destinationColumn.tasks.findIndex( - (task) => task.id === overId, - ); - const newTasks = [...destinationColumn.tasks]; - newTasks.splice(destinationIndex, 0, updatedTask); - - updatedColumns[destinationColumnIndex] = { - ...destinationColumn, - tasks: newTasks, - }; - } + destinationColumn.tasks.splice(destinationIndex, 0, updatedTask); } - - return { - ...currentProject, - columns: updatedColumns, - }; }); + setProject(updatedProject); setActiveId(null); }; + if (!project || !project?.columns) return ; + const activeTask = activeId ? project.columns .flatMap((col) => col.tasks) @@ -150,20 +89,19 @@ function KanbanBoard() {

- {selectedProject?.name} + {project?.name}

-
- {project.columns.map((column) => ( +
+ {project?.columns.map((column) => ( ))}
- {activeTask ? (
diff --git a/apps/web/src/components/kanban-board/task-card.tsx b/apps/web/src/components/kanban-board/task-card.tsx index cfe52a1..5d505e2 100644 --- a/apps/web/src/components/kanban-board/task-card.tsx +++ b/apps/web/src/components/kanban-board/task-card.tsx @@ -1,8 +1,8 @@ -import type { Task } from "@/types/workspace"; +import type { Task } from "@/types/project"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { format } from "date-fns"; -import { Calendar, Flag } from "lucide-react"; +import { Calendar, Flag, UserIcon } from "lucide-react"; interface TaskCardProps { task: Task; @@ -38,18 +38,15 @@ function TaskCard({ task }: TaskCardProps) { style={style} {...attributes} {...listeners} - className="bg-white dark:bg-zinc-800/50 backdrop-blur-xs rounded-lg border border-zinc-200 dark:border-zinc-700/50 p-3 cursor-move hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors shadow-xs" + className="group bg-white dark:bg-zinc-800/50 backdrop-blur-sm rounded-lg border border-zinc-200 dark:border-zinc-700/50 p-3 cursor-move hover:border-zinc-300 dark:hover:border-zinc-700 transition-colors shadow-sm" > -

- {task.title} -

- -

- {task.description} -

- -
-
+
+
+

+ {task.title} +

+
+
@@ -57,11 +54,40 @@ function TaskCard({ task }: TaskCardProps) { {task.priority}
+
+ +

+ {task.description} +

+ +
+ {task.assigneeName ? ( +
+ + {task.assigneeName} + +
+ ) : ( +
+ + + Unassigned + +
+ )} {task.dueDate && ( -
- - {format(new Date(task.dueDate), "MMM d")} +
+ + + {format(new Date(task.dueDate), "MMM d")} +
)}
diff --git a/apps/web/src/components/task/create-task-modal.tsx b/apps/web/src/components/task/create-task-modal.tsx new file mode 100644 index 0000000..0922633 --- /dev/null +++ b/apps/web/src/components/task/create-task-modal.tsx @@ -0,0 +1,224 @@ +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import useCreateTask from "@/hooks/mutations/task/use-create-task"; +import useGetWorkspaceUsers from "@/hooks/queries/workspace-users/use-get-workspace-users"; +import useProjectStore from "@/store/project"; +import useWorkspaceStore from "@/store/workspace"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as Dialog from "@radix-ui/react-dialog"; +import { produce } from "immer"; +import { X } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +interface CreateTaskModalProps { + open: boolean; + onClose: () => void; + status?: string; +} + +const taskSchema = z.object({ + title: z.string().min(1, { message: "Title is required" }), + description: z.string().optional(), + priority: z.enum(["low", "medium", "high", "urgent"]), + assigneeId: z.string(), +}); + +type TaskFormValues = z.infer; + +export function CreateTaskModal({ + open, + onClose, + status, +}: CreateTaskModalProps) { + const { project, setProject } = useProjectStore(); + const { workspace } = useWorkspaceStore(); + const { data: users } = useGetWorkspaceUsers({ + workspaceId: workspace?.id ?? "", + }); + + const form = useForm({ + resolver: zodResolver(taskSchema), + defaultValues: { + title: "", + description: "", + priority: "low", + assigneeId: "", + }, + }); + const { mutateAsync } = useCreateTask(); + + const onSubmit = async (data: TaskFormValues) => { + if (!project?.id || !workspace?.id) return; + + const newTask = await mutateAsync({ + title: data.title.trim(), + description: data.description?.trim() || "", + assigneeId: data.assigneeId, + priority: data.priority, + projectId: project?.id, + dueDate: new Date(), + status: status ?? "to-do", + }); + + setProject( + produce(project, (draft) => { + const targetColumn = draft.columns?.find( + (col) => col.id === newTask.status, + ); + if (targetColumn) { + targetColumn.tasks.push(newTask); + } + }), + ); + + form.reset(); + onClose(); + }; + + return ( + + + + +
+
+ + New Task + + + + +
+ +
+ +
+ ( + + + Title + + + + + + + )} + /> + + ( + + + Description + + +